Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit b895a73

Browse files
elijahrpre-commit-ci[bot]cobaltt7
authored
Fix psf#1245: Preserve comment formatting in fmt:off/on blocks (psf#4811)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: cobalt <[email protected]>
1 parent f735434 commit b895a73

5 files changed

Lines changed: 401 additions & 94 deletions

File tree

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515

1616
<!-- Changes that affect Black's stable style -->
1717

18+
- Fix bug where comments between `# fmt: off` and `# fmt: on` were reformatted (#4811)
19+
- Comments containing fmt directives now preserve their exact formatting instead of
20+
being normalized (#4811)
21+
1822
### Preview style
1923

2024
<!-- Changes that affect Black's preview style -->

src/black/comments.py

Lines changed: 233 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,18 @@ def make_comment(content: str, mode: Mode) -> str:
155155
space between the hash sign and the content.
156156
157157
If `content` didn't start with a hash sign, one is provided.
158+
159+
Comments containing fmt directives are preserved exactly as-is to respect
160+
user intent (e.g., `#no space # fmt: skip` stays as-is).
158161
"""
159162
content = content.rstrip()
160163
if not content:
161164
return "#"
162165

166+
# Preserve comments with fmt directives exactly as-is
167+
if content.startswith("#") and _contains_fmt_directive(content):
168+
return content
169+
163170
if content[0] == "#":
164171
content = content[1:]
165172
if (
@@ -191,6 +198,126 @@ def normalize_fmt_off(
191198
try_again = convert_one_fmt_off_pair(node, mode, lines)
192199

193200

201+
def _should_process_fmt_comment(
202+
comment: ProtoComment, leaf: Leaf
203+
) -> tuple[bool, bool, bool]:
204+
"""Check if comment should be processed for fmt handling.
205+
206+
Returns (should_process, is_fmt_off, is_fmt_skip).
207+
"""
208+
is_fmt_off = _contains_fmt_directive(comment.value, FMT_OFF)
209+
is_fmt_skip = _contains_fmt_directive(comment.value, FMT_SKIP)
210+
211+
if not is_fmt_off and not is_fmt_skip:
212+
return False, False, False
213+
214+
# Invalid use when `# fmt: off` is applied before a closing bracket
215+
if is_fmt_off and leaf.type in CLOSING_BRACKETS:
216+
return False, False, False
217+
218+
return True, is_fmt_off, is_fmt_skip
219+
220+
221+
def _is_valid_standalone_fmt_comment(
222+
comment: ProtoComment, leaf: Leaf, is_fmt_off: bool, is_fmt_skip: bool
223+
) -> bool:
224+
"""Check if comment is a valid standalone fmt directive.
225+
226+
We only want standalone comments. If there's no previous leaf or if
227+
the previous leaf is indentation, it's a standalone comment in disguise.
228+
"""
229+
if comment.type == STANDALONE_COMMENT:
230+
return True
231+
232+
prev = preceding_leaf(leaf)
233+
if not prev:
234+
return True
235+
236+
# Treat STANDALONE_COMMENT nodes as whitespace for check
237+
if is_fmt_off and prev.type not in WHITESPACE and prev.type != STANDALONE_COMMENT:
238+
return False
239+
if is_fmt_skip and prev.type in WHITESPACE:
240+
return False
241+
242+
return True
243+
244+
245+
def _handle_comment_only_fmt_block(
246+
leaf: Leaf,
247+
comment: ProtoComment,
248+
previous_consumed: int,
249+
mode: Mode,
250+
) -> bool:
251+
"""Handle fmt:off/on blocks that contain only comments.
252+
253+
Returns True if a block was converted, False otherwise.
254+
"""
255+
all_comments = list_comments(leaf.prefix, is_endmarker=False, mode=mode)
256+
257+
# Find the first fmt:off and its matching fmt:on
258+
fmt_off_idx = None
259+
fmt_on_idx = None
260+
for idx, c in enumerate(all_comments):
261+
if fmt_off_idx is None and c.value in FMT_OFF:
262+
fmt_off_idx = idx
263+
if fmt_off_idx is not None and idx > fmt_off_idx and c.value in FMT_ON:
264+
fmt_on_idx = idx
265+
break
266+
267+
# Only proceed if we found both directives
268+
if fmt_on_idx is None or fmt_off_idx is None:
269+
return False
270+
271+
comment = all_comments[fmt_off_idx]
272+
fmt_on_comment = all_comments[fmt_on_idx]
273+
original_prefix = leaf.prefix
274+
275+
# Build the hidden value
276+
start_pos = comment.consumed
277+
end_pos = fmt_on_comment.consumed
278+
content_between_and_fmt_on = original_prefix[start_pos:end_pos]
279+
hidden_value = comment.value + "\n" + content_between_and_fmt_on
280+
281+
if hidden_value.endswith("\n"):
282+
hidden_value = hidden_value[:-1]
283+
284+
# Build the standalone comment prefix
285+
standalone_comment_prefix = (
286+
original_prefix[:previous_consumed] + "\n" * comment.newlines
287+
)
288+
289+
fmt_off_prefix = original_prefix.split(comment.value)[0]
290+
if "\n" in fmt_off_prefix:
291+
fmt_off_prefix = fmt_off_prefix.split("\n")[-1]
292+
standalone_comment_prefix += fmt_off_prefix
293+
294+
# Update leaf prefix
295+
leaf.prefix = original_prefix[fmt_on_comment.consumed :]
296+
297+
# Insert the STANDALONE_COMMENT
298+
parent = leaf.parent
299+
assert parent is not None, "INTERNAL ERROR: fmt: on/off handling (prefix only)"
300+
301+
leaf_idx = None
302+
for idx, child in enumerate(parent.children):
303+
if child is leaf:
304+
leaf_idx = idx
305+
break
306+
307+
assert leaf_idx is not None, "INTERNAL ERROR: fmt: on/off handling (leaf index)"
308+
309+
parent.insert_child(
310+
leaf_idx,
311+
Leaf(
312+
STANDALONE_COMMENT,
313+
hidden_value,
314+
prefix=standalone_comment_prefix,
315+
fmt_pass_converted_first_leaf=None,
316+
),
317+
)
318+
return True
319+
320+
194321
def convert_one_fmt_off_pair(
195322
node: Node, mode: Mode, lines: Collection[tuple[int, int]]
196323
) -> bool:
@@ -201,82 +328,112 @@ def convert_one_fmt_off_pair(
201328
for leaf in node.leaves():
202329
previous_consumed = 0
203330
for comment in list_comments(leaf.prefix, is_endmarker=False, mode=mode):
204-
is_fmt_off = comment.value in FMT_OFF
205-
is_fmt_skip = _contains_fmt_skip_comment(comment.value, mode)
206-
if (not is_fmt_off and not is_fmt_skip) or (
207-
# Invalid use when `# fmt: off` is applied before a closing bracket.
208-
is_fmt_off
209-
and leaf.type in CLOSING_BRACKETS
331+
should_process, is_fmt_off, is_fmt_skip = _should_process_fmt_comment(
332+
comment, leaf
333+
)
334+
if not should_process:
335+
previous_consumed = comment.consumed
336+
continue
337+
338+
if not _is_valid_standalone_fmt_comment(
339+
comment, leaf, is_fmt_off, is_fmt_skip
210340
):
211341
previous_consumed = comment.consumed
212342
continue
213-
# We only want standalone comments. If there's no previous leaf or
214-
# the previous leaf is indentation, it's a standalone comment in
215-
# disguise.
216-
if comment.type != STANDALONE_COMMENT:
217-
prev = preceding_leaf(leaf)
218-
if prev:
219-
if is_fmt_off and prev.type not in WHITESPACE:
220-
continue
221-
if is_fmt_skip and prev.type in WHITESPACE:
222-
continue
223343

224344
ignored_nodes = list(generate_ignored_nodes(leaf, comment, mode))
345+
346+
# Handle comment-only blocks
347+
if not ignored_nodes and is_fmt_off:
348+
if _handle_comment_only_fmt_block(
349+
leaf, comment, previous_consumed, mode
350+
):
351+
return True
352+
continue
353+
354+
# Need actual nodes to process
225355
if not ignored_nodes:
226356
continue
227357

228-
first = ignored_nodes[0] # Can be a container node with the `leaf`.
229-
parent = first.parent
230-
prefix = first.prefix
231-
if comment.value in FMT_OFF:
232-
first.prefix = prefix[comment.consumed :]
233-
if is_fmt_skip:
234-
first.prefix = ""
235-
standalone_comment_prefix = prefix
236-
else:
237-
standalone_comment_prefix = (
238-
prefix[:previous_consumed] + "\n" * comment.newlines
239-
)
240-
hidden_value = "".join(str(n) for n in ignored_nodes)
241-
comment_lineno = leaf.lineno - comment.newlines
242-
if comment.value in FMT_OFF:
243-
fmt_off_prefix = ""
244-
if len(lines) > 0 and not any(
245-
line[0] <= comment_lineno <= line[1] for line in lines
246-
):
247-
# keeping indentation of comment by preserving original whitespaces.
248-
fmt_off_prefix = prefix.split(comment.value)[0]
249-
if "\n" in fmt_off_prefix:
250-
fmt_off_prefix = fmt_off_prefix.split("\n")[-1]
251-
standalone_comment_prefix += fmt_off_prefix
252-
hidden_value = comment.value + "\n" + hidden_value
253-
if is_fmt_skip:
254-
hidden_value += comment.leading_whitespace + comment.value
255-
if hidden_value.endswith("\n"):
256-
# That happens when one of the `ignored_nodes` ended with a NEWLINE
257-
# leaf (possibly followed by a DEDENT).
258-
hidden_value = hidden_value[:-1]
259-
first_idx: Optional[int] = None
260-
for ignored in ignored_nodes:
261-
index = ignored.remove()
262-
if first_idx is None:
263-
first_idx = index
264-
assert parent is not None, "INTERNAL ERROR: fmt: on/off handling (1)"
265-
assert first_idx is not None, "INTERNAL ERROR: fmt: on/off handling (2)"
266-
parent.insert_child(
267-
first_idx,
268-
Leaf(
269-
STANDALONE_COMMENT,
270-
hidden_value,
271-
prefix=standalone_comment_prefix,
272-
fmt_pass_converted_first_leaf=first_leaf_of(first),
273-
),
358+
# Handle regular fmt blocks
359+
360+
_handle_regular_fmt_block(
361+
ignored_nodes,
362+
comment,
363+
previous_consumed,
364+
is_fmt_skip,
365+
lines,
366+
leaf,
274367
)
275368
return True
276369

277370
return False
278371

279372

373+
def _handle_regular_fmt_block(
374+
ignored_nodes: list[LN],
375+
comment: ProtoComment,
376+
previous_consumed: int,
377+
is_fmt_skip: bool,
378+
lines: Collection[tuple[int, int]],
379+
leaf: Leaf,
380+
) -> None:
381+
"""Handle fmt blocks with actual AST nodes."""
382+
first = ignored_nodes[0] # Can be a container node with the `leaf`.
383+
parent = first.parent
384+
prefix = first.prefix
385+
386+
if comment.value in FMT_OFF:
387+
first.prefix = prefix[comment.consumed :]
388+
if is_fmt_skip:
389+
first.prefix = ""
390+
standalone_comment_prefix = prefix
391+
else:
392+
standalone_comment_prefix = prefix[:previous_consumed] + "\n" * comment.newlines
393+
394+
hidden_value = "".join(str(n) for n in ignored_nodes)
395+
comment_lineno = leaf.lineno - comment.newlines
396+
397+
if comment.value in FMT_OFF:
398+
fmt_off_prefix = ""
399+
if len(lines) > 0 and not any(
400+
line[0] <= comment_lineno <= line[1] for line in lines
401+
):
402+
# keeping indentation of comment by preserving original whitespaces.
403+
fmt_off_prefix = prefix.split(comment.value)[0]
404+
if "\n" in fmt_off_prefix:
405+
fmt_off_prefix = fmt_off_prefix.split("\n")[-1]
406+
standalone_comment_prefix += fmt_off_prefix
407+
hidden_value = comment.value + "\n" + hidden_value
408+
409+
if is_fmt_skip:
410+
hidden_value += comment.leading_whitespace + comment.value
411+
412+
if hidden_value.endswith("\n"):
413+
# That happens when one of the `ignored_nodes` ended with a NEWLINE
414+
# leaf (possibly followed by a DEDENT).
415+
hidden_value = hidden_value[:-1]
416+
417+
first_idx: Optional[int] = None
418+
for ignored in ignored_nodes:
419+
index = ignored.remove()
420+
if first_idx is None:
421+
first_idx = index
422+
423+
assert parent is not None, "INTERNAL ERROR: fmt: on/off handling (1)"
424+
assert first_idx is not None, "INTERNAL ERROR: fmt: on/off handling (2)"
425+
426+
parent.insert_child(
427+
first_idx,
428+
Leaf(
429+
STANDALONE_COMMENT,
430+
hidden_value,
431+
prefix=standalone_comment_prefix,
432+
fmt_pass_converted_first_leaf=first_leaf_of(first),
433+
),
434+
)
435+
436+
280437
def generate_ignored_nodes(
281438
leaf: Leaf, comment: ProtoComment, mode: Mode
282439
) -> Iterator[LN]:
@@ -285,7 +442,7 @@ def generate_ignored_nodes(
285442
If comment is skip, returns leaf only.
286443
Stops at the end of the block.
287444
"""
288-
if _contains_fmt_skip_comment(comment.value, mode):
445+
if _contains_fmt_directive(comment.value, FMT_SKIP):
289446
yield from _generate_ignored_nodes_from_fmt_skip(leaf, comment, mode)
290447
return
291448
container: Optional[LN] = container_of(leaf)
@@ -548,13 +705,20 @@ def contains_pragma_comment(comment_list: list[Leaf]) -> bool:
548705
return False
549706

550707

551-
def _contains_fmt_skip_comment(comment_line: str, mode: Mode) -> bool:
708+
def _contains_fmt_directive(
709+
comment_line: str, directives: set[str] = FMT_OFF | FMT_ON | FMT_SKIP
710+
) -> bool:
552711
"""
553-
Checks if the given comment contains FMT_SKIP alone or paired with other comments.
712+
Checks if the given comment contains format directives, alone or paired with
713+
other comments.
714+
715+
Defaults to checking all directives (skip, off, on, yapf), but can be
716+
narrowed to specific ones.
717+
554718
Matching styles:
555-
# fmt:skip <-- single comment
556-
# noqa:XXX # fmt:skip # a nice line <-- multiple comments (Preview)
557-
# pylint:XXX; fmt:skip <-- list of comments (; separated, Preview)
719+
# foobar <-- single comment
720+
# foobar # foobar # foobar <-- multiple comments
721+
# foobar; foobar <-- list of comments (; separated)
558722
"""
559723
semantic_comment_blocks = [
560724
comment_line,
@@ -570,4 +734,4 @@ def _contains_fmt_skip_comment(comment_line: str, mode: Mode) -> bool:
570734
],
571735
]
572736

573-
return any(comment in FMT_SKIP for comment in semantic_comment_blocks)
737+
return any(comment in directives for comment in semantic_comment_blocks)

0 commit comments

Comments
 (0)