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

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