Skip to content

Commit 8a187b2

Browse files
committed
Add helpers to find and add suppressions comments to nodes
ghstack-source-id: 92333ed Pull Request resolved: #451
1 parent 3483c12 commit 8a187b2

File tree

4 files changed

+392
-3
lines changed

4 files changed

+392
-3
lines changed

src/fixit/comments.py

+108-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# This source code is licensed under the MIT license found in the
44
# LICENSE file in the root directory of this source tree.
55

6-
from typing import Generator, Optional, Sequence
6+
from typing import Generator, List, Optional, Sequence
77

88
from libcst import (
99
BaseSuite,
@@ -12,15 +12,21 @@
1212
CSTNode,
1313
Decorator,
1414
EmptyLine,
15+
ensure_type,
1516
IndentedBlock,
1617
LeftSquareBracket,
18+
matchers as m,
1719
Module,
20+
ParenthesizedWhitespace,
1821
RightSquareBracket,
1922
SimpleStatementSuite,
23+
SimpleWhitespace,
2024
TrailingWhitespace,
2125
)
2226
from libcst.metadata import MetadataWrapper, ParentNodeProvider, PositionProvider
2327

28+
from .ftypes import LintIgnore, LintIgnoreStyle
29+
2430

2531
def node_comments(
2632
node: CSTNode, metadata: MetadataWrapper
@@ -111,3 +117,104 @@ def gen(node: CSTNode) -> Generator[Comment, None, None]:
111117
# to only include comments that are located on or before the line containing
112118
# the original node that we're searching from
113119
yield from (c for c in gen(node) if positions[c].end.line <= target_line)
120+
121+
122+
def node_nearest_comment(node: CSTNode, metadata: MetadataWrapper) -> CSTNode:
123+
"""
124+
Return the nearest tree node where a suppression comment could be added.
125+
"""
126+
parent_nodes = metadata.resolve(ParentNodeProvider)
127+
positions = metadata.resolve(PositionProvider)
128+
node_line = positions[node].start.line
129+
130+
while not isinstance(node, Module):
131+
if hasattr(node, "comment"):
132+
return node
133+
134+
if hasattr(node, "trailing_whitespace"):
135+
tw = ensure_type(node.trailing_whitespace, TrailingWhitespace)
136+
if tw and positions[tw].start.line == node_line:
137+
if tw.comment:
138+
return tw.comment
139+
else:
140+
return tw
141+
142+
if hasattr(node, "comma"):
143+
if m.matches(
144+
node.comma,
145+
m.Comma(
146+
whitespace_after=m.ParenthesizedWhitespace(
147+
first_line=m.TrailingWhitespace()
148+
)
149+
),
150+
):
151+
return ensure_type(
152+
node.comma.whitespace_after.first_line, TrailingWhitespace
153+
)
154+
155+
if hasattr(node, "rbracket"):
156+
tw = ensure_type(
157+
ensure_type(
158+
node.rbracket.whitespace_before,
159+
ParenthesizedWhitespace,
160+
).first_line,
161+
TrailingWhitespace,
162+
)
163+
if positions[tw].start.line == node_line:
164+
return tw
165+
166+
if hasattr(node, "leading_lines"):
167+
return node
168+
169+
parent = parent_nodes.get(node)
170+
if parent is None:
171+
break
172+
node = parent
173+
174+
raise RuntimeError("could not find nearest comment node")
175+
176+
177+
def add_suppression_comment(
178+
module: Module,
179+
node: CSTNode,
180+
metadata: MetadataWrapper,
181+
name: str,
182+
style: LintIgnoreStyle = LintIgnoreStyle.fixme,
183+
) -> Module:
184+
"""
185+
Return a modified tree that includes a suppression comment for the given rule.
186+
"""
187+
# reuse an existing suppression directive if available rather than making a new one
188+
for comment in node_comments(node, metadata):
189+
lint_ignore = LintIgnore.parse(comment.value)
190+
if lint_ignore and lint_ignore.style == style:
191+
if name in lint_ignore.names:
192+
return module # already suppressed
193+
lint_ignore.names.add(name)
194+
return module.with_deep_changes(comment, value=str(lint_ignore))
195+
196+
# no existing directives, find the "nearest" location and add a comment there
197+
target = node_nearest_comment(node, metadata)
198+
lint_ignore = LintIgnore(style, {name})
199+
200+
if isinstance(target, Comment):
201+
lint_ignore.prefix = target.value.strip()
202+
return module.with_deep_changes(target, value=str(lint_ignore))
203+
204+
if isinstance(target, TrailingWhitespace):
205+
if target.comment:
206+
lint_ignore.prefix = target.comment.value.strip()
207+
return module.with_deep_changes(target.comment, value=str(lint_ignore))
208+
else:
209+
return module.with_deep_changes(
210+
target,
211+
comment=Comment(str(lint_ignore)),
212+
whitespace=SimpleWhitespace(" "),
213+
)
214+
215+
if hasattr(target, "leading_lines"):
216+
ll: List[EmptyLine] = list(target.leading_lines or ())
217+
ll.append(EmptyLine(comment=Comment(str(lint_ignore))))
218+
return module.with_deep_changes(target, leading_lines=ll)
219+
220+
raise RuntimeError("failed to add suppression comment")

src/fixit/ftypes.py

+31-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
List,
2020
Optional,
2121
Sequence,
22+
Set,
2223
Tuple,
2324
TypedDict,
2425
TypeVar,
@@ -29,6 +30,7 @@
2930
from libcst._add_slots import add_slots
3031
from libcst.metadata import CodePosition as CodePosition, CodeRange as CodeRange
3132
from packaging.version import Version
33+
from typing_extensions import Self
3234

3335
__all__ = ("Version",)
3436

@@ -74,7 +76,7 @@ class LintIgnoreStyle(Enum):
7476
LintIgnoreRegex = re.compile(
7577
r"""
7678
\#\s* # leading hash and whitespace
77-
(lint-(?:ignore|fixme)) # directive
79+
(?:lint-(ignore|fixme)) # directive
7880
(?:
7981
(?::\s*|\s+) # separator
8082
(
@@ -87,6 +89,34 @@ class LintIgnoreStyle(Enum):
8789
)
8890

8991

92+
@dataclass
93+
class LintIgnore:
94+
style: LintIgnoreStyle
95+
names: Set[str] = field(default_factory=set)
96+
prefix: str = ""
97+
postfix: str = ""
98+
99+
@classmethod
100+
def parse(cls, value: str) -> Optional[Self]:
101+
value = value.strip()
102+
if match := LintIgnoreRegex.search(value):
103+
style, raw_names = match.groups()
104+
names = {n.strip() for n in raw_names.split(",")} if raw_names else set()
105+
start, end = match.span()
106+
prefix = value[:start].strip()
107+
postfix = value[end:]
108+
return cls(LintIgnoreStyle(style), names, prefix, postfix)
109+
110+
return None
111+
112+
def __str__(self) -> str:
113+
if self.names:
114+
directive = f"# lint-{self.style.value}: {', '.join(sorted(self.names))}"
115+
else:
116+
directive = f"# lint-{self.style.value}"
117+
return f"{self.prefix} {directive}{self.postfix}".strip()
118+
119+
90120
QualifiedRuleRegex = re.compile(
91121
r"""
92122
^

0 commit comments

Comments
 (0)