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

Set Docker to use 3.11 for now (#3927)
[etc/vim.git] / src / black / comments.py
1 import re
2 from dataclasses import dataclass
3 from functools import lru_cache
4 from typing import Final, Iterator, List, Optional, Union
5
6 from black.nodes import (
7     CLOSING_BRACKETS,
8     STANDALONE_COMMENT,
9     WHITESPACE,
10     container_of,
11     first_leaf_of,
12     preceding_leaf,
13     syms,
14 )
15 from blib2to3.pgen2 import token
16 from blib2to3.pytree import Leaf, Node
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 = " !:#'"
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) -> 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(leaf.prefix, is_endmarker=leaf.type == token.ENDMARKER):
67         yield Leaf(pc.type, pc.value, prefix="\n" * pc.newlines)
68
69
70 @lru_cache(maxsize=4096)
71 def list_comments(prefix: str, *, is_endmarker: bool) -> List[ProtoComment]:
72     """Return a list of :class:`ProtoComment` objects parsed from the given `prefix`."""
73     result: List[ProtoComment] = []
74     if not prefix or "#" not in prefix:
75         return result
76
77     consumed = 0
78     nlines = 0
79     ignored_lines = 0
80     for index, line in enumerate(re.split("\r?\n", prefix)):
81         consumed += len(line) + 1  # adding the length of the split '\n'
82         line = line.lstrip()
83         if not line:
84             nlines += 1
85         if not line.startswith("#"):
86             # Escaped newlines outside of a comment are not really newlines at
87             # all. We treat a single-line comment following an escaped newline
88             # as a simple trailing comment.
89             if line.endswith("\\"):
90                 ignored_lines += 1
91             continue
92
93         if index == ignored_lines and not is_endmarker:
94             comment_type = token.COMMENT  # simple trailing comment
95         else:
96             comment_type = STANDALONE_COMMENT
97         comment = make_comment(line)
98         result.append(
99             ProtoComment(
100                 type=comment_type, value=comment, newlines=nlines, consumed=consumed
101             )
102         )
103         nlines = 0
104     return result
105
106
107 def make_comment(content: str) -> str:
108     """Return a consistently formatted comment from the given `content` string.
109
110     All comments (except for "##", "#!", "#:", '#'") should have a single
111     space between the hash sign and the content.
112
113     If `content` didn't start with a hash sign, one is provided.
114     """
115     content = content.rstrip()
116     if not content:
117         return "#"
118
119     if content[0] == "#":
120         content = content[1:]
121     NON_BREAKING_SPACE = " "
122     if (
123         content
124         and content[0] == NON_BREAKING_SPACE
125         and not content.lstrip().startswith("type:")
126     ):
127         content = " " + content[1:]  # Replace NBSP by a simple space
128     if content and content[0] not in COMMENT_EXCEPTIONS:
129         content = " " + content
130     return "#" + content
131
132
133 def normalize_fmt_off(node: Node) -> None:
134     """Convert content between `# fmt: off`/`# fmt: on` into standalone comments."""
135     try_again = True
136     while try_again:
137         try_again = convert_one_fmt_off_pair(node)
138
139
140 def convert_one_fmt_off_pair(node: Node) -> bool:
141     """Convert content of a single `# fmt: off`/`# fmt: on` into a standalone comment.
142
143     Returns True if a pair was converted.
144     """
145     for leaf in node.leaves():
146         previous_consumed = 0
147         for comment in list_comments(leaf.prefix, is_endmarker=False):
148             if comment.value not in FMT_PASS:
149                 previous_consumed = comment.consumed
150                 continue
151             # We only want standalone comments. If there's no previous leaf or
152             # the previous leaf is indentation, it's a standalone comment in
153             # disguise.
154             if comment.value in FMT_PASS and comment.type != STANDALONE_COMMENT:
155                 prev = preceding_leaf(leaf)
156                 if prev:
157                     if comment.value in FMT_OFF and prev.type not in WHITESPACE:
158                         continue
159                     if comment.value in FMT_SKIP and prev.type in WHITESPACE:
160                         continue
161
162             ignored_nodes = list(generate_ignored_nodes(leaf, comment))
163             if not ignored_nodes:
164                 continue
165
166             first = ignored_nodes[0]  # Can be a container node with the `leaf`.
167             parent = first.parent
168             prefix = first.prefix
169             if comment.value in FMT_OFF:
170                 first.prefix = prefix[comment.consumed :]
171             if comment.value in FMT_SKIP:
172                 first.prefix = ""
173                 standalone_comment_prefix = prefix
174             else:
175                 standalone_comment_prefix = (
176                     prefix[:previous_consumed] + "\n" * comment.newlines
177                 )
178             hidden_value = "".join(str(n) for n in ignored_nodes)
179             if comment.value in FMT_OFF:
180                 hidden_value = comment.value + "\n" + hidden_value
181             if comment.value in FMT_SKIP:
182                 hidden_value += "  " + comment.value
183             if hidden_value.endswith("\n"):
184                 # That happens when one of the `ignored_nodes` ended with a NEWLINE
185                 # leaf (possibly followed by a DEDENT).
186                 hidden_value = hidden_value[:-1]
187             first_idx: Optional[int] = None
188             for ignored in ignored_nodes:
189                 index = ignored.remove()
190                 if first_idx is None:
191                     first_idx = index
192             assert parent is not None, "INTERNAL ERROR: fmt: on/off handling (1)"
193             assert first_idx is not None, "INTERNAL ERROR: fmt: on/off handling (2)"
194             parent.insert_child(
195                 first_idx,
196                 Leaf(
197                     STANDALONE_COMMENT,
198                     hidden_value,
199                     prefix=standalone_comment_prefix,
200                     fmt_pass_converted_first_leaf=first_leaf_of(first),
201                 ),
202             )
203             return True
204
205     return False
206
207
208 def generate_ignored_nodes(leaf: Leaf, comment: ProtoComment) -> Iterator[LN]:
209     """Starting from the container of `leaf`, generate all leaves until `# fmt: on`.
210
211     If comment is skip, returns leaf only.
212     Stops at the end of the block.
213     """
214     if comment.value in FMT_SKIP:
215         yield from _generate_ignored_nodes_from_fmt_skip(leaf, comment)
216         return
217     container: Optional[LN] = container_of(leaf)
218     while container is not None and container.type != token.ENDMARKER:
219         if is_fmt_on(container):
220             return
221
222         # fix for fmt: on in children
223         if children_contains_fmt_on(container):
224             for index, child in enumerate(container.children):
225                 if isinstance(child, Leaf) and is_fmt_on(child):
226                     if child.type in CLOSING_BRACKETS:
227                         # This means `# fmt: on` is placed at a different bracket level
228                         # than `# fmt: off`. This is an invalid use, but as a courtesy,
229                         # we include this closing bracket in the ignored nodes.
230                         # The alternative is to fail the formatting.
231                         yield child
232                     return
233                 if (
234                     child.type == token.INDENT
235                     and index < len(container.children) - 1
236                     and children_contains_fmt_on(container.children[index + 1])
237                 ):
238                     # This means `# fmt: on` is placed right after an indentation
239                     # level, and we shouldn't swallow the previous INDENT token.
240                     return
241                 if children_contains_fmt_on(child):
242                     return
243                 yield child
244         else:
245             if container.type == token.DEDENT and container.next_sibling is None:
246                 # This can happen when there is no matching `# fmt: on` comment at the
247                 # same level as `# fmt: on`. We need to keep this DEDENT.
248                 return
249             yield container
250             container = container.next_sibling
251
252
253 def _generate_ignored_nodes_from_fmt_skip(
254     leaf: Leaf, comment: ProtoComment
255 ) -> Iterator[LN]:
256     """Generate all leaves that should be ignored by the `# fmt: skip` from `leaf`."""
257     prev_sibling = leaf.prev_sibling
258     parent = leaf.parent
259     # Need to properly format the leaf prefix to compare it to comment.value,
260     # which is also formatted
261     comments = list_comments(leaf.prefix, is_endmarker=False)
262     if not comments or comment.value != comments[0].value:
263         return
264     if prev_sibling is not None:
265         leaf.prefix = ""
266         siblings = [prev_sibling]
267         while "\n" not in prev_sibling.prefix and prev_sibling.prev_sibling is not None:
268             prev_sibling = prev_sibling.prev_sibling
269             siblings.insert(0, prev_sibling)
270         yield from siblings
271     elif (
272         parent is not None and parent.type == syms.suite and leaf.type == token.NEWLINE
273     ):
274         # The `# fmt: skip` is on the colon line of the if/while/def/class/...
275         # statements. The ignored nodes should be previous siblings of the
276         # parent suite node.
277         leaf.prefix = ""
278         ignored_nodes: List[LN] = []
279         parent_sibling = parent.prev_sibling
280         while parent_sibling is not None and parent_sibling.type != syms.suite:
281             ignored_nodes.insert(0, parent_sibling)
282             parent_sibling = parent_sibling.prev_sibling
283         # Special case for `async_stmt` where the ASYNC token is on the
284         # grandparent node.
285         grandparent = parent.parent
286         if (
287             grandparent is not None
288             and grandparent.prev_sibling is not None
289             and grandparent.prev_sibling.type == token.ASYNC
290         ):
291             ignored_nodes.insert(0, grandparent.prev_sibling)
292         yield from iter(ignored_nodes)
293
294
295 def is_fmt_on(container: LN) -> bool:
296     """Determine whether formatting is switched on within a container.
297     Determined by whether the last `# fmt:` comment is `on` or `off`.
298     """
299     fmt_on = False
300     for comment in list_comments(container.prefix, is_endmarker=False):
301         if comment.value in FMT_ON:
302             fmt_on = True
303         elif comment.value in FMT_OFF:
304             fmt_on = False
305     return fmt_on
306
307
308 def children_contains_fmt_on(container: LN) -> bool:
309     """Determine if children have formatting switched on."""
310     for child in container.children:
311         leaf = first_leaf_of(child)
312         if leaf is not None and is_fmt_on(leaf):
313             return True
314
315     return False
316
317
318 def contains_pragma_comment(comment_list: List[Leaf]) -> bool:
319     """
320     Returns:
321         True iff one of the comments in @comment_list is a pragma used by one
322         of the more common static analysis tools for python (e.g. mypy, flake8,
323         pylint).
324     """
325     for comment in comment_list:
326         if comment.value.startswith(("# type:", "# noqa", "# pylint:")):
327             return True
328
329     return False