File size: 30,306 Bytes
be7647c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
import re
from collections.abc import Collection, Iterator
from dataclasses import dataclass
from functools import lru_cache
from typing import Final, Union

from black.mode import Mode, Preview
from black.nodes import (
    CLOSING_BRACKETS,
    STANDALONE_COMMENT,
    STATEMENT,
    WHITESPACE,
    container_of,
    first_leaf_of,
    is_type_comment_string,
    make_simple_prefix,
    preceding_leaf,
    syms,
)
from blib2to3.pgen2 import token
from blib2to3.pytree import Leaf, Node

# types
LN = Union[Leaf, Node]

FMT_OFF: Final = {"# fmt: off", "# fmt:off", "# yapf: disable"}
FMT_SKIP: Final = {"# fmt: skip", "# fmt:skip"}
FMT_ON: Final = {"# fmt: on", "# fmt:on", "# yapf: enable"}

# Compound statements we care about for fmt: skip handling
# (excludes except_clause and case_block which aren't standalone compound statements)
_COMPOUND_STATEMENTS: Final = STATEMENT - {syms.except_clause, syms.case_block}

COMMENT_EXCEPTIONS = " !:#'"
_COMMENT_PREFIX = "# "
_COMMENT_LIST_SEPARATOR = ";"


@dataclass
class ProtoComment:
    """Describes a piece of syntax that is a comment.

    It's not a :class:`blib2to3.pytree.Leaf` so that:

    * it can be cached (`Leaf` objects should not be reused more than once as
      they store their lineno, column, prefix, and parent information);
    * `newlines` and `consumed` fields are kept separate from the `value`. This
      simplifies handling of special marker comments like ``# fmt: off/on``.
    """

    type: int  # token.COMMENT or STANDALONE_COMMENT
    value: str  # content of the comment
    newlines: int  # how many newlines before the comment
    consumed: int  # how many characters of the original leaf's prefix did we consume
    form_feed: bool  # is there a form feed before the comment
    leading_whitespace: str  # leading whitespace before the comment, if any


def generate_comments(leaf: LN, mode: Mode) -> Iterator[Leaf]:
    """Clean the prefix of the `leaf` and generate comments from it, if any.

    Comments in lib2to3 are shoved into the whitespace prefix.  This happens
    in `pgen2/driver.py:Driver.parse_tokens()`.  This was a brilliant implementation
    move because it does away with modifying the grammar to include all the
    possible places in which comments can be placed.

    The sad consequence for us though is that comments don't "belong" anywhere.
    This is why this function generates simple parentless Leaf objects for
    comments.  We simply don't know what the correct parent should be.

    No matter though, we can live without this.  We really only need to
    differentiate between inline and standalone comments.  The latter don't
    share the line with any code.

    Inline comments are emitted as regular token.COMMENT leaves.  Standalone
    are emitted with a fake STANDALONE_COMMENT token identifier.
    """
    total_consumed = 0
    for pc in list_comments(
        leaf.prefix, is_endmarker=leaf.type == token.ENDMARKER, mode=mode
    ):
        total_consumed = pc.consumed
        prefix = make_simple_prefix(pc.newlines, pc.form_feed)
        yield Leaf(pc.type, pc.value, prefix=prefix)
    normalize_trailing_prefix(leaf, total_consumed)


@lru_cache(maxsize=4096)
def list_comments(prefix: str, *, is_endmarker: bool, mode: Mode) -> list[ProtoComment]:
    """Return a list of :class:`ProtoComment` objects parsed from the given `prefix`."""
    result: list[ProtoComment] = []
    if not prefix or "#" not in prefix:
        return result

    consumed = 0
    nlines = 0
    ignored_lines = 0
    form_feed = False
    for index, full_line in enumerate(re.split("\r?\n|\r", prefix)):
        consumed += len(full_line) + 1  # adding the length of the split '\n'
        match = re.match(r"^(\s*)(\S.*|)$", full_line)
        assert match
        whitespace, line = match.groups()
        if not line:
            nlines += 1
            if "\f" in full_line:
                form_feed = True
        if not line.startswith("#"):
            # Escaped newlines outside of a comment are not really newlines at
            # all. We treat a single-line comment following an escaped newline
            # as a simple trailing comment.
            if line.endswith("\\"):
                ignored_lines += 1
            continue

        if index == ignored_lines and not is_endmarker:
            comment_type = token.COMMENT  # simple trailing comment
        else:
            comment_type = STANDALONE_COMMENT
        comment = make_comment(line, mode=mode)
        result.append(
            ProtoComment(
                type=comment_type,
                value=comment,
                newlines=nlines,
                consumed=consumed,
                form_feed=form_feed,
                leading_whitespace=whitespace,
            )
        )
        form_feed = False
        nlines = 0
    return result


def normalize_trailing_prefix(leaf: LN, total_consumed: int) -> None:
    """Normalize the prefix that's left over after generating comments.

    Note: don't use backslashes for formatting or you'll lose your voting rights.
    """
    remainder = leaf.prefix[total_consumed:]
    if "\\" not in remainder:
        nl_count = remainder.count("\n")
        form_feed = "\f" in remainder and remainder.endswith("\n")
        leaf.prefix = make_simple_prefix(nl_count, form_feed)
        return

    leaf.prefix = ""


def make_comment(content: str, mode: Mode) -> str:
    """Return a consistently formatted comment from the given `content` string.

    All comments (except for "##", "#!", "#:", '#'") should have a single
    space between the hash sign and the content.

    If `content` didn't start with a hash sign, one is provided.

    Comments containing fmt directives are preserved exactly as-is to respect
    user intent (e.g., `#no space # fmt: skip` stays as-is).
    """
    content = content.rstrip()
    if not content:
        return "#"

    # Preserve comments with fmt directives exactly as-is
    if content.startswith("#") and contains_fmt_directive(content):
        return content

    if content[0] == "#":
        content = content[1:]
    if (
        content
        and content[0] == "\N{NO-BREAK SPACE}"
        and not is_type_comment_string("# " + content.lstrip(), mode=mode)
    ):
        content = " " + content[1:]  # Replace NBSP by a simple space
    if (
        Preview.standardize_type_comments in mode
        and content
        and "\N{NO-BREAK SPACE}" not in content
        and is_type_comment_string("#" + content, mode=mode)
    ):
        type_part, value_part = content.split(":", 1)
        content = type_part.strip() + ": " + value_part.strip()

    if content and content[0] not in COMMENT_EXCEPTIONS:
        content = " " + content
    return "#" + content


def normalize_fmt_off(
    node: Node, mode: Mode, lines: Collection[tuple[int, int]]
) -> None:
    """Convert content between `# fmt: off`/`# fmt: on` into standalone comments."""
    try_again = True
    while try_again:
        try_again = convert_one_fmt_off_pair(node, mode, lines)


def _should_process_fmt_comment(
    comment: ProtoComment, leaf: Leaf
) -> tuple[bool, bool, bool]:
    """Check if comment should be processed for fmt handling.

    Returns (should_process, is_fmt_off, is_fmt_skip).
    """
    is_fmt_off = contains_fmt_directive(comment.value, FMT_OFF)
    is_fmt_skip = contains_fmt_directive(comment.value, FMT_SKIP)

    if not is_fmt_off and not is_fmt_skip:
        return False, False, False

    # Invalid use when `# fmt: off` is applied before a closing bracket
    if is_fmt_off and leaf.type in CLOSING_BRACKETS:
        return False, False, False

    return True, is_fmt_off, is_fmt_skip


def _is_valid_standalone_fmt_comment(
    comment: ProtoComment, leaf: Leaf, is_fmt_off: bool, is_fmt_skip: bool
) -> bool:
    """Check if comment is a valid standalone fmt directive.

    We only want standalone comments. If there's no previous leaf or if
    the previous leaf is indentation, it's a standalone comment in disguise.
    """
    if comment.type == STANDALONE_COMMENT:
        return True

    prev = preceding_leaf(leaf)
    if not prev:
        return True

    # Treat STANDALONE_COMMENT nodes as whitespace for check
    if is_fmt_off and prev.type not in WHITESPACE and prev.type != STANDALONE_COMMENT:
        return False
    if is_fmt_skip and prev.type in WHITESPACE:
        return False

    return True


def _handle_comment_only_fmt_block(
    leaf: Leaf,
    comment: ProtoComment,
    previous_consumed: int,
    mode: Mode,
) -> bool:
    """Handle fmt:off/on blocks that contain only comments.

    Returns True if a block was converted, False otherwise.
    """
    all_comments = list_comments(leaf.prefix, is_endmarker=False, mode=mode)

    # Find the first fmt:off and its matching fmt:on
    fmt_off_idx = None
    fmt_on_idx = None
    for idx, c in enumerate(all_comments):
        if fmt_off_idx is None and contains_fmt_directive(c.value, FMT_OFF):
            fmt_off_idx = idx
        if (
            fmt_off_idx is not None
            and idx > fmt_off_idx
            and contains_fmt_directive(c.value, FMT_ON)
        ):
            fmt_on_idx = idx
            break

    # Only proceed if we found both directives
    if fmt_on_idx is None or fmt_off_idx is None:
        return False

    comment = all_comments[fmt_off_idx]
    fmt_on_comment = all_comments[fmt_on_idx]
    original_prefix = leaf.prefix

    # Build the hidden value
    start_pos = comment.consumed
    end_pos = fmt_on_comment.consumed
    content_between_and_fmt_on = original_prefix[start_pos:end_pos]
    hidden_value = comment.value + "\n" + content_between_and_fmt_on

    if hidden_value.endswith("\n"):
        hidden_value = hidden_value[:-1]

    # Build the standalone comment prefix - preserve all content before fmt:off
    # including any comments that precede it
    if fmt_off_idx == 0:
        # No comments before fmt:off, use previous_consumed
        pre_fmt_off_consumed = previous_consumed
    else:
        # Use the consumed position of the last comment before fmt:off
        # This preserves all comments and content before the fmt:off directive
        pre_fmt_off_consumed = all_comments[fmt_off_idx - 1].consumed

    standalone_comment_prefix = (
        original_prefix[:pre_fmt_off_consumed] + "\n" * comment.newlines
    )

    fmt_off_prefix = original_prefix.split(comment.value)[0]
    if "\n" in fmt_off_prefix:
        fmt_off_prefix = fmt_off_prefix.split("\n")[-1]
    standalone_comment_prefix += fmt_off_prefix

    # Update leaf prefix
    leaf.prefix = original_prefix[fmt_on_comment.consumed :]

    # Insert the STANDALONE_COMMENT
    parent = leaf.parent
    assert parent is not None, "INTERNAL ERROR: fmt: on/off handling (prefix only)"

    leaf_idx = None
    for idx, child in enumerate(parent.children):
        if child is leaf:
            leaf_idx = idx
            break

    assert leaf_idx is not None, "INTERNAL ERROR: fmt: on/off handling (leaf index)"

    parent.insert_child(
        leaf_idx,
        Leaf(
            STANDALONE_COMMENT,
            hidden_value,
            prefix=standalone_comment_prefix,
            fmt_pass_converted_first_leaf=None,
        ),
    )
    return True


def convert_one_fmt_off_pair(
    node: Node, mode: Mode, lines: Collection[tuple[int, int]]
) -> bool:
    """Convert content of a single `# fmt: off`/`# fmt: on` into a standalone comment.

    Returns True if a pair was converted.
    """
    for leaf in node.leaves():
        # Skip STANDALONE_COMMENT nodes that were created by fmt:off/on/skip processing
        # to avoid reprocessing them in subsequent iterations
        if leaf.type == STANDALONE_COMMENT and hasattr(
            leaf, "fmt_pass_converted_first_leaf"
        ):
            continue

        previous_consumed = 0
        for comment in list_comments(leaf.prefix, is_endmarker=False, mode=mode):
            should_process, is_fmt_off, is_fmt_skip = _should_process_fmt_comment(
                comment, leaf
            )
            if not should_process:
                previous_consumed = comment.consumed
                continue

            if not _is_valid_standalone_fmt_comment(
                comment, leaf, is_fmt_off, is_fmt_skip
            ):
                previous_consumed = comment.consumed
                continue

            ignored_nodes = list(generate_ignored_nodes(leaf, comment, mode))

            # Handle comment-only blocks
            if not ignored_nodes and is_fmt_off:
                if _handle_comment_only_fmt_block(
                    leaf, comment, previous_consumed, mode
                ):
                    return True
                continue

            # Need actual nodes to process
            if not ignored_nodes:
                continue

            # Handle regular fmt blocks

            _handle_regular_fmt_block(
                ignored_nodes,
                comment,
                previous_consumed,
                is_fmt_skip,
                lines,
                leaf,
            )
            return True

    return False


def _handle_regular_fmt_block(
    ignored_nodes: list[LN],
    comment: ProtoComment,
    previous_consumed: int,
    is_fmt_skip: bool,
    lines: Collection[tuple[int, int]],
    leaf: Leaf,
) -> None:
    """Handle fmt blocks with actual AST nodes."""
    first = ignored_nodes[0]  # Can be a container node with the `leaf`.
    parent = first.parent
    prefix = first.prefix

    if contains_fmt_directive(comment.value, FMT_OFF):
        first.prefix = prefix[comment.consumed :]
    if is_fmt_skip:
        first.prefix = ""
        standalone_comment_prefix = prefix
    else:
        standalone_comment_prefix = prefix[:previous_consumed] + "\n" * comment.newlines

    # Ensure STANDALONE_COMMENT nodes have trailing newlines when stringified
    # This prevents multiple fmt: skip comments from being concatenated on one line
    parts = []
    for node in ignored_nodes:
        if isinstance(node, Leaf) and node.type == STANDALONE_COMMENT:
            # Add newline after STANDALONE_COMMENT Leaf
            node_str = str(node)
            if not node_str.endswith("\n"):
                node_str += "\n"
            parts.append(node_str)
        elif isinstance(node, Node):
            # For nodes that might contain STANDALONE_COMMENT leaves,
            # we need custom stringify
            has_standalone = any(
                leaf.type == STANDALONE_COMMENT for leaf in node.leaves()
            )
            if has_standalone:
                # Stringify node with STANDALONE_COMMENT leaves having trailing newlines
                def stringify_node(n: LN) -> str:
                    if isinstance(n, Leaf):
                        if n.type == STANDALONE_COMMENT:
                            result = n.prefix + n.value
                            if not result.endswith("\n"):
                                result += "\n"
                            return result
                        return str(n)
                    else:
                        # For nested nodes, recursively process children
                        return "".join(stringify_node(child) for child in n.children)

                parts.append(stringify_node(node))
            else:
                parts.append(str(node))
        else:
            parts.append(str(node))

    hidden_value = "".join(parts)
    comment_lineno = leaf.lineno - comment.newlines

    if contains_fmt_directive(comment.value, FMT_OFF):
        fmt_off_prefix = ""
        if len(lines) > 0 and not any(
            line[0] <= comment_lineno <= line[1] for line in lines
        ):
            # keeping indentation of comment by preserving original whitespaces.
            fmt_off_prefix = prefix.split(comment.value)[0]
            if "\n" in fmt_off_prefix:
                fmt_off_prefix = fmt_off_prefix.split("\n")[-1]
        standalone_comment_prefix += fmt_off_prefix
        hidden_value = comment.value + "\n" + hidden_value

    if is_fmt_skip:
        hidden_value += comment.leading_whitespace + comment.value

    if hidden_value.endswith("\n"):
        # That happens when one of the `ignored_nodes` ended with a NEWLINE
        # leaf (possibly followed by a DEDENT).
        hidden_value = hidden_value[:-1]

    first_idx: int | None = None
    for ignored in ignored_nodes:
        index = ignored.remove()
        if first_idx is None:
            first_idx = index

    assert parent is not None, "INTERNAL ERROR: fmt: on/off handling (1)"
    assert first_idx is not None, "INTERNAL ERROR: fmt: on/off handling (2)"

    parent.insert_child(
        first_idx,
        Leaf(
            STANDALONE_COMMENT,
            hidden_value,
            prefix=standalone_comment_prefix,
            fmt_pass_converted_first_leaf=first_leaf_of(first),
        ),
    )


def generate_ignored_nodes(
    leaf: Leaf, comment: ProtoComment, mode: Mode
) -> Iterator[LN]:
    """Starting from the container of `leaf`, generate all leaves until `# fmt: on`.

    If comment is skip, returns leaf only.
    Stops at the end of the block.
    """
    if contains_fmt_directive(comment.value, FMT_SKIP):
        yield from _generate_ignored_nodes_from_fmt_skip(leaf, comment, mode)
        return
    container: LN | None = container_of(leaf)
    while container is not None and container.type != token.ENDMARKER:
        if is_fmt_on(container, mode=mode):
            return

        # fix for fmt: on in children
        if children_contains_fmt_on(container, mode=mode):
            for index, child in enumerate(container.children):
                if isinstance(child, Leaf) and is_fmt_on(child, mode=mode):
                    if child.type in CLOSING_BRACKETS:
                        # This means `# fmt: on` is placed at a different bracket level
                        # than `# fmt: off`. This is an invalid use, but as a courtesy,
                        # we include this closing bracket in the ignored nodes.
                        # The alternative is to fail the formatting.
                        yield child
                    return
                if (
                    child.type == token.INDENT
                    and index < len(container.children) - 1
                    and children_contains_fmt_on(
                        container.children[index + 1], mode=mode
                    )
                ):
                    # This means `# fmt: on` is placed right after an indentation
                    # level, and we shouldn't swallow the previous INDENT token.
                    return
                if children_contains_fmt_on(child, mode=mode):
                    return
                yield child
        else:
            if container.type == token.DEDENT and container.next_sibling is None:
                # This can happen when there is no matching `# fmt: on` comment at the
                # same level as `# fmt: on`. We need to keep this DEDENT.
                return
            yield container
            container = container.next_sibling


def _find_compound_statement_context(parent: Node) -> Node | None:
    """Return the body node of a compound statement if we should respect fmt: skip.

    This handles one-line compound statements like:
        if condition: body  # fmt: skip

    When Black expands such statements, they temporarily look like:
        if condition:
            body  # fmt: skip

    In both cases, we want to return the body node (either the simple_stmt directly
    or the suite containing it).
    """
    if parent.type != syms.simple_stmt:
        return None

    if not isinstance(parent.parent, Node):
        return None

    # Case 1: Expanded form after Black's initial formatting pass.
    # The one-liner has been split across multiple lines:
    #     if True:
    #         print("a"); print("b")  # fmt: skip
    # Structure: compound_stmt -> suite -> simple_stmt
    if (
        parent.parent.type == syms.suite
        and isinstance(parent.parent.parent, Node)
        and parent.parent.parent.type in _COMPOUND_STATEMENTS
    ):
        return parent.parent

    # Case 2: Original one-line form from the input source.
    # The statement is still on a single line:
    #     if True: print("a"); print("b")  # fmt: skip
    # Structure: compound_stmt -> simple_stmt
    if parent.parent.type in _COMPOUND_STATEMENTS:
        return parent

    return None


def _should_keep_compound_statement_inline(
    body_node: Node, simple_stmt_parent: Node
) -> bool:
    """Check if a compound statement should be kept on one line.

    Returns True only for compound statements with semicolon-separated bodies,
    like: if True: print("a"); print("b")  # fmt: skip
    """
    # Check if there are semicolons in the body
    for leaf in body_node.leaves():
        if leaf.type == token.SEMI:
            # Verify it's a single-line body (one simple_stmt)
            if body_node.type == syms.suite:
                # After formatting: check suite has one simple_stmt child
                simple_stmts = [
                    child
                    for child in body_node.children
                    if child.type == syms.simple_stmt
                ]
                return len(simple_stmts) == 1 and simple_stmts[0] is simple_stmt_parent
            else:
                # Original form: body_node IS the simple_stmt
                return body_node is simple_stmt_parent
    return False


def _get_compound_statement_header(
    body_node: Node, simple_stmt_parent: Node
) -> list[LN]:
    """Get header nodes for a compound statement that should be preserved inline."""
    if not _should_keep_compound_statement_inline(body_node, simple_stmt_parent):
        return []

    # Get the compound statement (parent of body)
    compound_stmt = body_node.parent
    if compound_stmt is None or compound_stmt.type not in _COMPOUND_STATEMENTS:
        return []

    # Collect all header leaves before the body
    header_leaves: list[LN] = []
    for child in compound_stmt.children:
        if child is body_node:
            break
        if isinstance(child, Leaf):
            if child.type not in (token.NEWLINE, token.INDENT):
                header_leaves.append(child)
        else:
            header_leaves.extend(child.leaves())
    return header_leaves


def _generate_ignored_nodes_from_fmt_skip(
    leaf: Leaf, comment: ProtoComment, mode: Mode
) -> Iterator[LN]:
    """Generate all leaves that should be ignored by the `# fmt: skip` from `leaf`."""
    prev_sibling = leaf.prev_sibling
    parent = leaf.parent
    ignored_nodes: list[LN] = []
    # Need to properly format the leaf prefix to compare it to comment.value,
    # which is also formatted
    comments = list_comments(leaf.prefix, is_endmarker=False, mode=mode)
    if not comments or comment.value != comments[0].value:
        return

    if Preview.fix_fmt_skip_in_one_liners in mode and not prev_sibling and parent:
        prev_sibling = parent.prev_sibling

    if prev_sibling is not None:
        leaf.prefix = leaf.prefix[comment.consumed :]

        if Preview.fix_fmt_skip_in_one_liners not in mode:
            siblings = [prev_sibling]
            while (
                "\n" not in prev_sibling.prefix
                and prev_sibling.prev_sibling is not None
            ):
                prev_sibling = prev_sibling.prev_sibling
                siblings.insert(0, prev_sibling)
            yield from siblings
            return

        # Generates the nodes to be ignored by `fmt: skip`.

        # Nodes to ignore are the ones on the same line as the
        # `# fmt: skip` comment, excluding the `# fmt: skip`
        # node itself.

        # Traversal process (starting at the `# fmt: skip` node):
        # 1. Move to the `prev_sibling` of the current node.
        # 2. If `prev_sibling` has children, go to its rightmost leaf.
        # 3. If there's no `prev_sibling`, move up to the parent
        # node and repeat.
        # 4. Continue until:
        #    a. You encounter an `INDENT` or `NEWLINE` node (indicates
        #       start of the line).
        #    b. You reach the root node.

        # Include all visited LEAVES in the ignored list, except INDENT
        # or NEWLINE leaves.

        current_node = prev_sibling
        ignored_nodes = [current_node]
        if current_node.prev_sibling is None and current_node.parent is not None:
            current_node = current_node.parent

        # Track seen nodes to detect cycles that can occur after tree modifications
        seen_nodes = {id(current_node)}

        while "\n" not in current_node.prefix and current_node.prev_sibling is not None:
            leaf_nodes = list(current_node.prev_sibling.leaves())
            next_node = leaf_nodes[-1] if leaf_nodes else current_node

            # Detect infinite loop - if we've seen this node before, stop
            # This can happen when STANDALONE_COMMENT nodes are inserted
            # during processing
            if id(next_node) in seen_nodes:
                break

            current_node = next_node
            seen_nodes.add(id(current_node))

            # Stop if we encounter a STANDALONE_COMMENT created by fmt processing
            if (
                isinstance(current_node, Leaf)
                and current_node.type == STANDALONE_COMMENT
                and hasattr(current_node, "fmt_pass_converted_first_leaf")
            ):
                break

            if (
                current_node.type in CLOSING_BRACKETS
                and current_node.parent
                and current_node.parent.type == syms.atom
            ):
                current_node = current_node.parent

            if current_node.type in (token.NEWLINE, token.INDENT):
                current_node.prefix = ""
                break

            if current_node.type == token.DEDENT:
                break

            # Special case for with expressions
            # Without this, we can stuck inside the asexpr_test's children's children
            if (
                current_node.parent
                and current_node.parent.type == syms.asexpr_test
                and current_node.parent.parent
                and current_node.parent.parent.type == syms.with_stmt
            ):
                current_node = current_node.parent

            ignored_nodes.insert(0, current_node)

            if current_node.prev_sibling is None and current_node.parent is not None:
                current_node = current_node.parent

        # Special handling for compound statements with semicolon-separated bodies
        if Preview.fix_fmt_skip_in_one_liners in mode and isinstance(parent, Node):
            body_node = _find_compound_statement_context(parent)
            if body_node is not None:
                header_nodes = _get_compound_statement_header(body_node, parent)
                if header_nodes:
                    ignored_nodes = header_nodes + ignored_nodes

        yield from ignored_nodes
    elif (
        parent is not None and parent.type == syms.suite and leaf.type == token.NEWLINE
    ):
        # The `# fmt: skip` is on the colon line of the if/while/def/class/...
        # statements. The ignored nodes should be previous siblings of the
        # parent suite node.
        leaf.prefix = ""
        parent_sibling = parent.prev_sibling
        while parent_sibling is not None and parent_sibling.type != syms.suite:
            ignored_nodes.insert(0, parent_sibling)
            parent_sibling = parent_sibling.prev_sibling
        # Special case for `async_stmt` where the ASYNC token is on the
        # grandparent node.
        grandparent = parent.parent
        if (
            grandparent is not None
            and grandparent.prev_sibling is not None
            and grandparent.prev_sibling.type == token.ASYNC
        ):
            ignored_nodes.insert(0, grandparent.prev_sibling)
        yield from iter(ignored_nodes)


def is_fmt_on(container: LN, mode: Mode) -> bool:
    """Determine whether formatting is switched on within a container.
    Determined by whether the last `# fmt:` comment is `on` or `off`.
    """
    fmt_on = False
    for comment in list_comments(container.prefix, is_endmarker=False, mode=mode):
        if contains_fmt_directive(comment.value, FMT_ON):
            fmt_on = True
        elif contains_fmt_directive(comment.value, FMT_OFF):
            fmt_on = False
    return fmt_on


def children_contains_fmt_on(container: LN, mode: Mode) -> bool:
    """Determine if children have formatting switched on."""
    for child in container.children:
        leaf = first_leaf_of(child)
        if leaf is not None and is_fmt_on(leaf, mode=mode):
            return True

    return False


def contains_pragma_comment(comment_list: list[Leaf]) -> bool:
    """
    Returns:
        True iff one of the comments in @comment_list is a pragma used by one
        of the more common static analysis tools for python (e.g. mypy, flake8,
        pylint).
    """
    for comment in comment_list:
        if comment.value.startswith(("# type:", "# noqa", "# pylint:")):
            return True

    return False


def contains_fmt_directive(
    comment_line: str, directives: set[str] = FMT_OFF | FMT_ON | FMT_SKIP
) -> bool:
    """
    Checks if the given comment contains format directives, alone or paired with
    other comments.

    Defaults to checking all directives (skip, off, on, yapf), but can be
    narrowed to specific ones.

    Matching styles:
      # foobar                    <-- single comment
      # foobar # foobar # foobar  <-- multiple comments
      # foobar; foobar            <-- list of comments (; separated)
    """
    semantic_comment_blocks = [
        comment_line,
        *[
            _COMMENT_PREFIX + comment.strip()
            for comment in comment_line.split(_COMMENT_PREFIX)[1:]
        ],
        *[
            _COMMENT_PREFIX + comment.strip()
            for comment in comment_line.strip(_COMMENT_PREFIX).split(
                _COMMENT_LIST_SEPARATOR
            )
        ],
    ]

    return any(comment in directives for comment in semantic_comment_blocks)