]> git.madduck.net Git - etc/vim.git/blob - src/black/comments.py

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

All patches and comments are welcome. Please squash your changes to logical commits before using git-format-patch and git-send-email to patches@git.madduck.net. If you'd read over the Git project's submission guidelines and adhered to them, I'd be especially grateful.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

Fix an infinite loop when using `# fmt: on/off` ... (#3158)
[etc/vim.git] / src / black / comments.py
1 import sys
2 from dataclasses import dataclass
3 from functools import lru_cache
4 import re
5 from typing import Iterator, List, Optional, Union
6
7 if sys.version_info >= (3, 8):
8     from typing import Final
9 else:
10     from typing_extensions import Final
11
12 from blib2to3.pytree import Node, Leaf, type_repr
13 from blib2to3.pgen2 import token
14
15 from black.nodes import first_leaf_column, preceding_leaf, container_of
16 from black.nodes import CLOSING_BRACKETS, STANDALONE_COMMENT, WHITESPACE
17
18 # types
19 LN = Union[Leaf, Node]
20
21 FMT_OFF: Final = {"# fmt: off", "# fmt:off", "# yapf: disable"}
22 FMT_SKIP: Final = {"# fmt: skip", "# fmt:skip"}
23 FMT_PASS: Final = {*FMT_OFF, *FMT_SKIP}
24 FMT_ON: Final = {"# fmt: on", "# fmt:on", "# yapf: enable"}
25
26 COMMENT_EXCEPTIONS = {True: " !:#'", False: " !:#'%"}
27
28
29 @dataclass
30 class ProtoComment:
31     """Describes a piece of syntax that is a comment.
32
33     It's not a :class:`blib2to3.pytree.Leaf` so that:
34
35     * it can be cached (`Leaf` objects should not be reused more than once as
36       they store their lineno, column, prefix, and parent information);
37     * `newlines` and `consumed` fields are kept separate from the `value`. This
38       simplifies handling of special marker comments like ``# fmt: off/on``.
39     """
40
41     type: int  # token.COMMENT or STANDALONE_COMMENT
42     value: str  # content of the comment
43     newlines: int  # how many newlines before the comment
44     consumed: int  # how many characters of the original leaf's prefix did we consume
45
46
47 def generate_comments(leaf: LN, *, preview: bool) -> Iterator[Leaf]:
48     """Clean the prefix of the `leaf` and generate comments from it, if any.
49
50     Comments in lib2to3 are shoved into the whitespace prefix.  This happens
51     in `pgen2/driver.py:Driver.parse_tokens()`.  This was a brilliant implementation
52     move because it does away with modifying the grammar to include all the
53     possible places in which comments can be placed.
54
55     The sad consequence for us though is that comments don't "belong" anywhere.
56     This is why this function generates simple parentless Leaf objects for
57     comments.  We simply don't know what the correct parent should be.
58
59     No matter though, we can live without this.  We really only need to
60     differentiate between inline and standalone comments.  The latter don't
61     share the line with any code.
62
63     Inline comments are emitted as regular token.COMMENT leaves.  Standalone
64     are emitted with a fake STANDALONE_COMMENT token identifier.
65     """
66     for pc in list_comments(
67         leaf.prefix, is_endmarker=leaf.type == token.ENDMARKER, preview=preview
68     ):
69         yield Leaf(pc.type, pc.value, prefix="\n" * pc.newlines)
70
71
72 @lru_cache(maxsize=4096)
73 def list_comments(
74     prefix: str, *, is_endmarker: bool, preview: bool
75 ) -> List[ProtoComment]:
76     """Return a list of :class:`ProtoComment` objects parsed from the given `prefix`."""
77     result: List[ProtoComment] = []
78     if not prefix or "#" not in prefix:
79         return result
80
81     consumed = 0
82     nlines = 0
83     ignored_lines = 0
84     for index, line in enumerate(re.split("\r?\n", prefix)):
85         consumed += len(line) + 1  # adding the length of the split '\n'
86         line = line.lstrip()
87         if not line:
88             nlines += 1
89         if not line.startswith("#"):
90             # Escaped newlines outside of a comment are not really newlines at
91             # all. We treat a single-line comment following an escaped newline
92             # as a simple trailing comment.
93             if line.endswith("\\"):
94                 ignored_lines += 1
95             continue
96
97         if index == ignored_lines and not is_endmarker:
98             comment_type = token.COMMENT  # simple trailing comment
99         else:
100             comment_type = STANDALONE_COMMENT
101         comment = make_comment(line, preview=preview)
102         result.append(
103             ProtoComment(
104                 type=comment_type, value=comment, newlines=nlines, consumed=consumed
105             )
106         )
107         nlines = 0
108     return result
109
110
111 def make_comment(content: str, *, preview: bool) -> str:
112     """Return a consistently formatted comment from the given `content` string.
113
114     All comments (except for "##", "#!", "#:", '#'") should have a single
115     space between the hash sign and the content.
116
117     If `content` didn't start with a hash sign, one is provided.
118     """
119     content = content.rstrip()
120     if not content:
121         return "#"
122
123     if content[0] == "#":
124         content = content[1:]
125     NON_BREAKING_SPACE = " "
126     if (
127         content
128         and content[0] == NON_BREAKING_SPACE
129         and not content.lstrip().startswith("type:")
130     ):
131         content = " " + content[1:]  # Replace NBSP by a simple space
132     if content and content[0] not in COMMENT_EXCEPTIONS[preview]:
133         content = " " + content
134     return "#" + content
135
136
137 def normalize_fmt_off(node: Node, *, preview: bool) -> None:
138     """Convert content between `# fmt: off`/`# fmt: on` into standalone comments."""
139     try_again = True
140     while try_again:
141         try_again = convert_one_fmt_off_pair(node, preview=preview)
142
143
144 def convert_one_fmt_off_pair(node: Node, *, preview: bool) -> bool:
145     """Convert content of a single `# fmt: off`/`# fmt: on` into a standalone comment.
146
147     Returns True if a pair was converted.
148     """
149     for leaf in node.leaves():
150         previous_consumed = 0
151         for comment in list_comments(leaf.prefix, is_endmarker=False, preview=preview):
152             if comment.value not in FMT_PASS:
153                 previous_consumed = comment.consumed
154                 continue
155             # We only want standalone comments. If there's no previous leaf or
156             # the previous leaf is indentation, it's a standalone comment in
157             # disguise.
158             if comment.value in FMT_PASS and comment.type != STANDALONE_COMMENT:
159                 prev = preceding_leaf(leaf)
160                 if prev:
161                     if comment.value in FMT_OFF and prev.type not in WHITESPACE:
162                         continue
163                     if comment.value in FMT_SKIP and prev.type in WHITESPACE:
164                         continue
165
166             ignored_nodes = list(generate_ignored_nodes(leaf, comment, preview=preview))
167             if not ignored_nodes:
168                 continue
169
170             first = ignored_nodes[0]  # Can be a container node with the `leaf`.
171             parent = first.parent
172             prefix = first.prefix
173             if comment.value in FMT_OFF:
174                 first.prefix = prefix[comment.consumed :]
175             if comment.value in FMT_SKIP:
176                 first.prefix = ""
177                 standalone_comment_prefix = prefix
178             else:
179                 standalone_comment_prefix = (
180                     prefix[:previous_consumed] + "\n" * comment.newlines
181                 )
182             hidden_value = "".join(str(n) for n in ignored_nodes)
183             if comment.value in FMT_OFF:
184                 hidden_value = comment.value + "\n" + hidden_value
185             if comment.value in FMT_SKIP:
186                 hidden_value += "  " + comment.value
187             if hidden_value.endswith("\n"):
188                 # That happens when one of the `ignored_nodes` ended with a NEWLINE
189                 # leaf (possibly followed by a DEDENT).
190                 hidden_value = hidden_value[:-1]
191             first_idx: Optional[int] = None
192             for ignored in ignored_nodes:
193                 index = ignored.remove()
194                 if first_idx is None:
195                     first_idx = index
196             assert parent is not None, "INTERNAL ERROR: fmt: on/off handling (1)"
197             assert first_idx is not None, "INTERNAL ERROR: fmt: on/off handling (2)"
198             parent.insert_child(
199                 first_idx,
200                 Leaf(
201                     STANDALONE_COMMENT,
202                     hidden_value,
203                     prefix=standalone_comment_prefix,
204                 ),
205             )
206             return True
207
208     return False
209
210
211 def generate_ignored_nodes(
212     leaf: Leaf, comment: ProtoComment, *, preview: bool
213 ) -> Iterator[LN]:
214     """Starting from the container of `leaf`, generate all leaves until `# fmt: on`.
215
216     If comment is skip, returns leaf only.
217     Stops at the end of the block.
218     """
219     if comment.value in FMT_SKIP:
220         yield from _generate_ignored_nodes_from_fmt_skip(leaf, comment, preview=preview)
221         return
222     container: Optional[LN] = container_of(leaf)
223     while container is not None and container.type != token.ENDMARKER:
224         if is_fmt_on(container, preview=preview):
225             return
226
227         # fix for fmt: on in children
228         if contains_fmt_on_at_column(container, leaf.column, preview=preview):
229             for child in container.children:
230                 if isinstance(child, Leaf) and is_fmt_on(child, preview=preview):
231                     if child.type in CLOSING_BRACKETS:
232                         # This means `# fmt: on` is placed at a different bracket level
233                         # than `# fmt: off`. This is an invalid use, but as a courtesy,
234                         # we include this closing bracket in the ignored nodes.
235                         # The alternative is to fail the formatting.
236                         yield child
237                     return
238                 if contains_fmt_on_at_column(child, leaf.column, preview=preview):
239                     return
240                 yield child
241         else:
242             yield container
243             container = container.next_sibling
244
245
246 def _generate_ignored_nodes_from_fmt_skip(
247     leaf: Leaf, comment: ProtoComment, *, preview: bool
248 ) -> Iterator[LN]:
249     """Generate all leaves that should be ignored by the `# fmt: skip` from `leaf`."""
250     prev_sibling = leaf.prev_sibling
251     parent = leaf.parent
252     # Need to properly format the leaf prefix to compare it to comment.value,
253     # which is also formatted
254     comments = list_comments(leaf.prefix, is_endmarker=False, preview=preview)
255     if not comments or comment.value != comments[0].value:
256         return
257     if prev_sibling is not None:
258         leaf.prefix = ""
259         siblings = [prev_sibling]
260         while "\n" not in prev_sibling.prefix and prev_sibling.prev_sibling is not None:
261             prev_sibling = prev_sibling.prev_sibling
262             siblings.insert(0, prev_sibling)
263         for sibling in siblings:
264             yield sibling
265     elif (
266         parent is not None
267         and type_repr(parent.type) == "suite"
268         and leaf.type == token.NEWLINE
269     ):
270         # The `# fmt: skip` is on the colon line of the if/while/def/class/...
271         # statements. The ignored nodes should be previous siblings of the
272         # parent suite node.
273         leaf.prefix = ""
274         ignored_nodes: List[LN] = []
275         parent_sibling = parent.prev_sibling
276         while parent_sibling is not None and type_repr(parent_sibling.type) != "suite":
277             ignored_nodes.insert(0, parent_sibling)
278             parent_sibling = parent_sibling.prev_sibling
279         # Special case for `async_stmt` where the ASYNC token is on the
280         # grandparent node.
281         grandparent = parent.parent
282         if (
283             grandparent is not None
284             and grandparent.prev_sibling is not None
285             and grandparent.prev_sibling.type == token.ASYNC
286         ):
287             ignored_nodes.insert(0, grandparent.prev_sibling)
288         yield from iter(ignored_nodes)
289
290
291 def is_fmt_on(container: LN, preview: bool) -> bool:
292     """Determine whether formatting is switched on within a container.
293     Determined by whether the last `# fmt:` comment is `on` or `off`.
294     """
295     fmt_on = False
296     for comment in list_comments(container.prefix, is_endmarker=False, preview=preview):
297         if comment.value in FMT_ON:
298             fmt_on = True
299         elif comment.value in FMT_OFF:
300             fmt_on = False
301     return fmt_on
302
303
304 def contains_fmt_on_at_column(container: LN, column: int, *, preview: bool) -> bool:
305     """Determine if children at a given column have formatting switched on."""
306     for child in container.children:
307         if (
308             isinstance(child, Node)
309             and first_leaf_column(child) == column
310             or isinstance(child, Leaf)
311             and child.column == column
312         ):
313             if is_fmt_on(child, preview=preview):
314                 return True
315
316     return False
317
318
319 def contains_pragma_comment(comment_list: List[Leaf]) -> bool:
320     """
321     Returns:
322         True iff one of the comments in @comment_list is a pragma used by one
323         of the more common static analysis tools for python (e.g. mypy, flake8,
324         pylint).
325     """
326     for comment in comment_list:
327         if comment.value.startswith(("# type:", "# noqa", "# pylint:")):
328             return True
329
330     return False