]> 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:

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