Skip to content

Commit 5b8f0d4

Browse files
jorenhammeta-codesync[bot]
authored andcommitted
Locate suppressions at their comment's # in pyrefly coverage (#4030)
Summary: `Suppression` now records the string-aware comment offset already known at parse time, so `pyrefly coverage report` no longer points a suppression at a `#` inside a string literal. Fixes #4022 Pull Request resolved: #4030 Test Plan: Regression tests added Reviewed By: stroxler Differential Revision: D111306424 Pulled By: NathanTempest fbshipit-source-id: 8cac5492c90d200fc19436c769bc12c09ccdf080
1 parent 52f6174 commit 5b8f0d4

4 files changed

Lines changed: 90 additions & 45 deletions

File tree

crates/pyrefly_python/src/ignore.rs

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -250,15 +250,18 @@ pub struct Suppression {
250250
/// This may differ from the line the suppression applies to
251251
/// (e.g., when the comment is on the line above).
252252
comment_line: LineNumber,
253+
/// Byte offset within `comment_line` of the `#` that starts the comment.
254+
comment_offset: usize,
253255
}
254256

255257
impl Suppression {
256258
/// A blanket suppression for `tool` that matches every error code.
257-
fn blanket(tool: Tool, comment_line: LineNumber) -> Self {
259+
fn blanket(tool: Tool, comment_line: LineNumber, comment_offset: usize) -> Self {
258260
Self {
259261
tool,
260262
kind: Vec::new(),
261263
comment_line,
264+
comment_offset,
262265
}
263266
}
264267

@@ -267,6 +270,11 @@ impl Suppression {
267270
self.comment_line
268271
}
269272

273+
/// Returns the byte offset of the comment's `#` within `comment_line`.
274+
pub fn comment_offset(&self) -> usize {
275+
self.comment_offset
276+
}
277+
270278
/// Returns the error codes that this suppression applies to.
271279
/// An empty slice means the suppression applies to all error codes.
272280
pub fn error_codes(&self) -> &[String] {
@@ -304,20 +312,18 @@ impl Ignore {
304312
for (idx, line_str) in code.lines().enumerate() {
305313
let (comment_start, new_state) = find_comment_start(line_str, in_triple_quote);
306314
in_triple_quote = new_state;
307-
let comments = if let Some(comment_start) = comment_start {
308-
&line_str[comment_start..]
309-
} else {
310-
""
311-
};
312315
let is_comment_only_line = comment_start
313316
.is_some_and(|comment_start| line_str[..comment_start].trim_start().is_empty());
314317
line = LineNumber::from_zero_indexed(idx as u32);
315318
if !pending.is_empty() && (line_str.is_empty() || !is_comment_only_line) {
316319
ignores.entry(line).or_default().append(&mut pending);
317320
}
318-
// We know `#` is at the beginning, so the first split is an empty string
319-
for x in comments.split('#').skip(1) {
320-
if let Some(supp) = Self::parse_ignore_comment(x, line) {
321+
let Some(comment_start) = comment_start else {
322+
continue;
323+
};
324+
// We know `#` is at `comment_start`, so the first split is an empty string
325+
for x in line_str[comment_start..].split('#').skip(1) {
326+
if let Some(supp) = Self::parse_ignore_comment(x, line, comment_start) {
321327
if is_comment_only_line {
322328
pending.push(supp);
323329
} else {
@@ -336,8 +342,12 @@ impl Ignore {
336342
}
337343

338344
/// Given the content of a comment, parse it as a suppression.
339-
/// The comment_line parameter indicates which line the comment is on.
340-
fn parse_ignore_comment(l: &str, comment_line: LineNumber) -> Option<Suppression> {
345+
/// `comment_line` and `comment_offset` locate the `#` starting the comment.
346+
fn parse_ignore_comment(
347+
l: &str,
348+
comment_line: LineNumber,
349+
comment_offset: usize,
350+
) -> Option<Suppression> {
341351
let mut lex = Lexer(l);
342352
lex.trim_start();
343353

@@ -361,9 +371,10 @@ impl Ignore {
361371
tool,
362372
kind: parse_error_codes(inside),
363373
comment_line,
374+
comment_offset,
364375
});
365376
} else if gap || lex.word_boundary() {
366-
return Some(Suppression::blanket(tool, comment_line));
377+
return Some(Suppression::blanket(tool, comment_line, comment_offset));
367378
}
368379
None
369380
}
@@ -509,20 +520,22 @@ pub fn parse_ignore_all(
509520
break;
510521
}
511522

512-
if let Some((tool, prev_line)) = prev_ignore {
523+
if let Some((tool, prev_line, prev_offset)) = prev_ignore {
513524
// The previous `# type: ignore` was followed by another comment or
514525
// blank line, so it is a whole-file suppression.
515-
res.push(Suppression::blanket(tool, prev_line));
526+
res.push(Suppression::blanket(tool, prev_line, prev_offset));
516527
prev_ignore = None;
517528
}
518529

519530
let mut lex = Lexer(trimmed);
520531
if !lex.starts_with("#") {
521532
continue;
522533
}
534+
// `trimmed` starts with `#`, so its offset is the line's leading whitespace.
535+
let comment_offset = raw_line.len() - raw_line.trim_start().len();
523536
lex.trim_start();
524537
if lex.starts_with("pyre-ignore-all-errors") {
525-
res.push(Suppression::blanket(Tool::Pyre, line));
538+
res.push(Suppression::blanket(Tool::Pyre, line, comment_offset));
526539
} else if let Some(tool) = lex.starts_with_tool() {
527540
lex.trim_start();
528541
if lex.starts_with("ignore-errors") {
@@ -554,12 +567,13 @@ pub fn parse_ignore_all(
554567
tool,
555568
kind,
556569
comment_line: line,
570+
comment_offset,
557571
});
558572
}
559573
} else if !seen_docstring && lex.starts_with("ignore") && lex.blank() {
560574
// After a docstring, bare `# type: ignore` is not recognized
561575
// as an ignore-all directive.
562-
prev_ignore = Some((tool, line));
576+
prev_ignore = Some((tool, line, comment_offset));
563577
}
564578
}
565579
}
@@ -648,16 +662,39 @@ x = """
648662
f("x = '''''' # pyrefly: ignore", &[(Tool::Pyrefly, 1)]);
649663
}
650664

665+
#[test]
666+
fn test_suppression_comment_offset() {
667+
fn f(x: &str, expect: &[(u32, usize)]) {
668+
assert_eq!(
669+
&Ignore::parse_ignores(x)
670+
.into_iter()
671+
.flat_map(|(_, xs)| xs.map(|x| (x.comment_line.get(), x.comment_offset)))
672+
.collect::<Vec<_>>(),
673+
expect,
674+
"{x:?}"
675+
);
676+
}
677+
678+
f("x = 1 # type: ignore", &[(1, 7)]);
679+
// Not the `#` inside the string literal
680+
f(r##"x: str = "#hash" # type: ignore"##, &[(1, 18)]);
681+
// Line starts inside a triple-quoted string that closes mid-line
682+
f("x = \"\"\"\n#fake\"\"\" # type: ignore", &[(2, 9)]);
683+
// A comment above code keeps its own line and offset
684+
f(" # type: ignore\nx = 1", &[(1, 2)]);
685+
}
686+
651687
#[test]
652688
fn test_parse_ignore_comment() {
653689
fn f(x: &str, tool: Option<Tool>, kind: &[&str]) {
654690
let dummy_line = LineNumber::default();
655691
assert_eq!(
656-
Ignore::parse_ignore_comment(x, dummy_line),
692+
Ignore::parse_ignore_comment(x, dummy_line, 0),
657693
tool.map(|tool| Suppression {
658694
tool,
659695
kind: kind.map(|x| (*x).to_owned()),
660696
comment_line: dummy_line,
697+
comment_offset: 0,
661698
}),
662699
"{x:?}"
663700
);
@@ -750,6 +787,7 @@ x = """
750787
tool: x.0,
751788
kind: x.2.iter().map(|x| (*x).to_owned()).collect(),
752789
comment_line: LineNumber::new(x.1).unwrap(),
790+
comment_offset: 0,
753791
})
754792
.collect::<Vec<_>>(),
755793
"{x:?}"
@@ -837,6 +875,7 @@ x = """
837875
tool: x.0,
838876
kind: x.2.iter().map(|x| (*x).to_owned()).collect(),
839877
comment_line: LineNumber::new(x.1).unwrap(),
878+
comment_offset: 0,
840879
})
841880
.collect::<Vec<_>>(),
842881
"{x:?}"

pyrefly/lib/commands/coverage/collect.rs

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ use pyrefly_config::error_kind::ErrorKind;
1616
use pyrefly_config::finder::ConfigFinder;
1717
use pyrefly_graph::index::Idx;
1818
use pyrefly_python::dunder;
19-
use pyrefly_python::ignore::Ignore;
2019
use pyrefly_python::module::Module;
2120
use pyrefly_python::module_name::ModuleName;
2221
use pyrefly_python::module_path::ModuleStyle;
@@ -35,6 +34,7 @@ use ruff_python_ast::Parameters;
3534
use ruff_python_ast::name::Name;
3635
use ruff_text_size::Ranged;
3736
use ruff_text_size::TextRange;
37+
use ruff_text_size::TextSize;
3838
use starlark_map::Hashed;
3939
use starlark_map::small_map::SmallMap;
4040
use starlark_map::small_set::SmallSet;
@@ -106,36 +106,20 @@ fn range_to_location(module: &Module, range: TextRange) -> Location {
106106
}
107107
}
108108

109-
/// Parse type-ignore suppressions from source using the multi-tool parser from `ignore.rs`.
109+
/// Collect the module's type-ignore suppressions, each located at the `#` starting its comment.
110110
fn parse_suppressions(module: &Module) -> Vec<ReportSuppression> {
111-
let source = module.lined_buffer().contents();
112-
let ignore = Ignore::new(source);
113111
let mut suppressions = Vec::new();
114-
let lines: Vec<&str> = source.lines().collect();
115-
116-
for (_line_number, supps) in ignore.iter() {
112+
for (_, supps) in module.ignore().iter() {
117113
for supp in supps {
118-
let comment_line_num = supp.comment_line().get() as usize;
119-
let column = comment_line_num
120-
.checked_sub(1)
121-
.and_then(|idx| lines.get(idx))
122-
.and_then(|line| line.find('#'))
123-
.map(|c| c + 1);
124-
let Some(column) = column else {
125-
continue;
126-
};
127-
114+
let offset = module.lined_buffer().line_start(supp.comment_line())
115+
+ TextSize::try_from(supp.comment_offset()).unwrap();
128116
suppressions.push(ReportSuppression {
129117
kind: supp.tool(),
130118
codes: supp.error_codes().to_vec(),
131-
location: Location {
132-
line: comment_line_num,
133-
column,
134-
},
119+
location: range_to_location(module, TextRange::empty(offset)),
135120
});
136121
}
137122
}
138-
139123
suppressions
140124
}
141125

pyrefly/lib/test/coverage/test_files/suppressions.expected.json

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
"names": [
55
"test.x",
66
"test.y",
7-
"test.z"
7+
"test.z",
8+
"test.s"
89
],
9-
"line_count": 9,
10+
"line_count": 10,
1011
"symbol_reports": [
1112
{
1213
"kind": "attr",
@@ -43,6 +44,18 @@
4344
"line": 8,
4445
"column": 1
4546
}
47+
},
48+
{
49+
"kind": "attr",
50+
"name": "test.s",
51+
"n_typable": 1,
52+
"n_typed": 1,
53+
"n_any": 0,
54+
"n_untyped": 0,
55+
"location": {
56+
"line": 9,
57+
"column": 1
58+
}
4659
}
4760
],
4861
"type_ignores": [
@@ -66,10 +79,18 @@
6679
"line": 8,
6780
"column": 8
6881
}
82+
},
83+
{
84+
"kind": "type",
85+
"codes": [],
86+
"location": {
87+
"line": 9,
88+
"column": 19
89+
}
6990
}
7091
],
71-
"n_typable": 0,
72-
"n_typed": 0,
92+
"n_typable": 1,
93+
"n_typed": 1,
7394
"n_any": 0,
7495
"n_untyped": 0,
7596
"coverage": 100.0,
@@ -79,7 +100,7 @@
79100
"n_function_params": 0,
80101
"n_method_params": 0,
81102
"n_classes": 0,
82-
"n_attrs": 3,
103+
"n_attrs": 4,
83104
"n_properties": 0,
84-
"n_type_ignores": 2
105+
"n_type_ignores": 3
85106
}

pyrefly/lib/test/coverage/test_files/suppressions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
x = 1 # pyrefly: ignore[error-code]
77
y = 2
88
z = 3 # pyrefly: ignore[code1, code2]
9+
s: str = "#hash" # type: ignore

0 commit comments

Comments
 (0)