Skip to content

Commit dbcb55a

Browse files
committed
Add helpers to find and add suppressions comments to nodes
ghstack-source-id: 9b82e32 Pull Request resolved: #451
1 parent c5a801d commit dbcb55a

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

@@ -81,7 +83,7 @@ class LintIgnoreStyle(Enum):
8183
LintIgnoreRegex = re.compile(
8284
r"""
8385
\#\s* # leading hash and whitespace
84-
(lint-(?:ignore|fixme)) # directive
86+
(?:lint-(ignore|fixme)) # directive
8587
(?:
8688
(?::\s*|\s+) # separator
8789
(
@@ -94,6 +96,34 @@ class LintIgnoreStyle(Enum):
9496
)
9597

9698

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

0 commit comments

Comments
 (0)