Skip to content

Commit 5f6c407

Browse files
feat(code-action): add ignore comment to suppress error
This PR address #675. It works by 1. By gathering all the errors whose range contains the current cursor. 2. Finding the column position of the first non-whitespace character of the line the cursor is on. Call this `column offset`. 3. Inserting an ignore comment on the line above the cursor; the comment is preceded by `column offset` single-spaces to match the prior indentation. In my tests I have found this simple scheme to be surprisingly robust. My first attempt used the ast exclusively, but the ast does not contain comments, nor a way to insert _comments_ into the tree, meaning, just for this one use case of programmatically adding ignore comments, I cannot update the document using the ast, and I found myself having to calculate whitespace offsets in my first ast only attempt. It is also worth pointing out that some LSPs might not _want_ to provide this kind of code action--they want to encourage the user to address the underlying cause, not ignore it. But because Pyrefly is new, there are many false positives and so I think this will be a welcome addition. Plus, the suppression comments in this code action reference specific error codes, so the user in the future can address the lints precisely. Future work is needed to address different types of ignore comments and a code action to - [ ] Add all ignore comments to a module - [ ] Add a top-level ignore comment to a module Both should be relatively straightforward to do by using the utility functions I added in `pyrefly/lib/state/ide.rs` Let me know your thoughts on this. Thanks!
1 parent 7cff39b commit 5f6c407

File tree

4 files changed

+224
-8
lines changed

4 files changed

+224
-8
lines changed

crates/pyrefly_python/src/ignore.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,15 +283,24 @@ impl Ignore {
283283
false
284284
}
285285

286-
/// Get all pyrefly ignores.
286+
#[inline]
287+
/// Get pyrefly ignores that are global, and do not have an error code.
288+
pub fn get_pyrefly_all_ignores(&self) -> SmallSet<LineNumber> {
289+
self._get_pyrefly_ignores(true)
290+
}
291+
292+
#[inline]
287293
pub fn get_pyrefly_ignores(&self) -> SmallSet<LineNumber> {
294+
self._get_pyrefly_ignores(false)
295+
}
296+
fn _get_pyrefly_ignores(&self, only_all: bool) -> SmallSet<LineNumber> {
288297
self.ignores
289298
.iter()
290299
.filter(|ignore| {
291300
ignore
292301
.1
293302
.iter()
294-
.any(|s| s.tool == Tool::Pyrefly && s.kind.is_empty())
303+
.any(|s| s.tool == Tool::Pyrefly && (!only_all || s.kind.is_empty()))
295304
})
296305
.map(|(line, _)| *line)
297306
.collect()
@@ -306,6 +315,8 @@ mod tests {
306315

307316
#[test]
308317
fn test_parse_ignores() {
318+
// TODO: The second component of `expect` (the error code)
319+
// is not actually tested.
309320
fn f(x: &str, expect: &[(Tool, u32)]) {
310321
assert_eq!(
311322
&Ignore::parse_ignores(x)
@@ -325,6 +336,7 @@ mod tests {
325336
"code # mypy: ignore\n# pyre-fixme\nmore code",
326337
&[(Tool::Mypy, 1), (Tool::Pyre, 3)],
327338
);
339+
f("# pyrefly: ignore[bad-]\n", &[(Tool::Pyrefly, 2)]);
328340
f(
329341
"# type: ignore\n# mypy: ignore\n# bad\n\ncode",
330342
&[(Tool::Any, 4), (Tool::Mypy, 4)],

pyrefly/lib/state/ide.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
use dupe::Dupe;
9+
use pyrefly_python::module::Module;
810
use pyrefly_python::module_name::ModuleName;
911
use pyrefly_python::short_identifier::ShortIdentifier;
1012
use pyrefly_python::symbol_kind::SymbolKind;
@@ -23,6 +25,7 @@ use crate::binding::binding::Key;
2325
use crate::binding::bindings::Bindings;
2426
use crate::binding::narrow::identifier_and_chain_for_expr;
2527
use crate::binding::narrow::identifier_and_chain_prefix_for_expr;
28+
use crate::error::error::Error;
2629
use crate::export::exports::Export;
2730
use crate::state::handle::Handle;
2831

@@ -186,3 +189,66 @@ pub fn insert_import_edit(
186189
);
187190
(position, insert_text)
188191
}
192+
193+
pub fn create_ignore_code_action(
194+
error: &Error,
195+
module_info: &Module,
196+
) -> Option<(String, Module, TextRange, String)> {
197+
let ignore_lines_in_module = module_info.ignore().get_pyrefly_ignores();
198+
let start_line = error.display_range().start.line;
199+
200+
if ignore_lines_in_module.contains(&start_line) {
201+
create_add_to_existing_ignore_action(error, module_info)
202+
} else {
203+
create_new_ignore_action(error, module_info)
204+
}
205+
}
206+
207+
fn create_add_to_existing_ignore_action(
208+
error: &Error,
209+
module_info: &Module,
210+
) -> Option<(String, Module, TextRange, String)> {
211+
let dec = error.display_range().start.line.decrement()?;
212+
let suppression_comment = module_info
213+
.lined_buffer()
214+
.content_in_line_range(dec, dec)
215+
.trim_end();
216+
let bracket_pos = suppression_comment.rfind(']')?;
217+
let row_offset = module_info.lined_buffer().line_start(dec);
218+
let text_range = TextRange::new(
219+
row_offset + TextSize::from(bracket_pos as u32),
220+
row_offset + TextSize::from((bracket_pos + 1) as u32),
221+
);
222+
223+
Some((
224+
format!("Add {} to above ignore comment", error.error_kind()),
225+
module_info.dupe(),
226+
text_range,
227+
format!(", {}]", error.error_kind().to_name()),
228+
))
229+
}
230+
231+
fn create_new_ignore_action(
232+
error: &Error,
233+
module_info: &Module,
234+
) -> Option<(String, Module, TextRange, String)> {
235+
let start = error.range().start();
236+
let display_pos = module_info.display_pos(start);
237+
let start = module_info.lined_buffer().line_start(display_pos.line);
238+
let line = module_info
239+
.lined_buffer()
240+
.content_in_line_range(display_pos.line, display_pos.line);
241+
let offset = line.find(|c: char| !c.is_whitespace()).unwrap_or(0);
242+
let leading_indentation = " ".repeat(offset as usize);
243+
244+
Some((
245+
format!("Suppress {} with ignore comment", error.error_kind()),
246+
module_info.dupe(),
247+
TextRange::new(start, start),
248+
format!(
249+
"{}# pyrefly: ignore[{}]\n",
250+
leading_indentation,
251+
error.error_kind().to_name()
252+
),
253+
))
254+
}

pyrefly/lib/state/lsp.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ use crate::export::exports::ExportLocation;
6969
use crate::graph::index::Idx;
7070
use crate::state::handle::Handle;
7171
use crate::state::ide::IntermediateDefinition;
72+
use crate::state::ide::create_ignore_code_action;
7273
use crate::state::ide::insert_import_edit;
7374
use crate::state::ide::key_to_intermediate_definition;
7475
use crate::state::require::Require;
@@ -1425,6 +1426,17 @@ impl<'a> Transaction<'a> {
14251426
_ => {}
14261427
}
14271428
}
1429+
code_actions.extend(
1430+
self.get_errors(vec![handle])
1431+
.collect_errors()
1432+
.shown
1433+
.iter()
1434+
// NOTE: For these suppressions the cursor needs to literally
1435+
// be on the error range, instead of just the line. This
1436+
// is typical with LSPs but worth noting.
1437+
.filter(|error| error.range().contains_range(range))
1438+
.filter_map(|error| create_ignore_code_action(error, &module_info)),
1439+
);
14281440
code_actions.sort_by(|(title1, _, _, _), (title2, _, _, _)| title1.cmp(title2));
14291441
Some(code_actions)
14301442
}

pyrefly/lib/test/lsp/code_actions.rs

Lines changed: 132 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,16 @@ fn apply_patch(info: &ModuleInfo, range: TextRange, patch: String) -> (String, S
2525
(before, after)
2626
}
2727

28-
fn get_test_report(state: &State, handle: &Handle, position: TextSize) -> String {
28+
fn get_test_report_insert_import(state: &State, handle: &Handle, position: TextSize) -> String {
2929
let mut report = "Code Actions Results:\n".to_owned();
3030
let transaction = state.transaction();
3131
for (title, info, range, patch) in transaction
3232
.local_quickfix_code_actions(handle, TextRange::new(position, position))
33-
.unwrap_or_default()
33+
.into_iter()
34+
.flatten()
35+
// NOTE: We do NOT patch buffer with ignore comment code action that is
36+
// tested separately
37+
.filter(|(title, _, _, _)| !title.contains("ignore"))
3438
{
3539
let (before, after) = apply_patch(&info, range, patch);
3640
report.push_str("# Title: ");
@@ -45,6 +49,128 @@ fn get_test_report(state: &State, handle: &Handle, position: TextSize) -> String
4549
report
4650
}
4751

52+
fn get_test_report_ignore_code_action(
53+
state: &State,
54+
handle: &Handle,
55+
position: TextSize,
56+
) -> String {
57+
let mut report = "Code Actions Results:\n".to_owned();
58+
let transaction = state.transaction();
59+
for (title, info, range, patch) in transaction
60+
.local_quickfix_code_actions(handle, TextRange::new(position, position))
61+
.into_iter()
62+
.flatten()
63+
// NOTE: We ONLY patch buffer with ignore comment code action so
64+
// we can test it separately
65+
.filter(|(title, _, _, _)| title.contains("ignore"))
66+
{
67+
let (before, after) = apply_patch(&info, range, patch);
68+
report.push_str("# Title: ");
69+
report.push_str(&title);
70+
report.push('\n');
71+
report.push_str("\n## Before:\n");
72+
report.push_str(&before);
73+
report.push_str("\n## After:\n");
74+
report.push_str(&after);
75+
report.push('\n');
76+
}
77+
report
78+
}
79+
80+
#[test]
81+
fn add_ignore_comment() {
82+
let report = get_batched_lsp_operations_report_allow_error(
83+
&[
84+
("a", "my_export = 3\nanother_thing = 4"),
85+
("b", "from a import another_thing\nmy_export\n# ^"),
86+
(
87+
"c",
88+
r#"
89+
def func(x: int) -> int:
90+
pass
91+
func(y)
92+
# ^"#,
93+
),
94+
(
95+
"d",
96+
r#"
97+
def func(x: int) -> int:
98+
# ^
99+
pass
100+
func(y)"#,
101+
),
102+
],
103+
get_test_report_ignore_code_action,
104+
);
105+
assert_eq!(
106+
r#"
107+
# a.py
108+
109+
# b.py
110+
2 | my_export
111+
^
112+
Code Actions Results:
113+
# Title: Suppress UnknownName with ignore comment
114+
115+
## Before:
116+
from a import another_thing
117+
my_export
118+
# ^
119+
## After:
120+
from a import another_thing
121+
# pyrefly: ignore[unknown-name]
122+
my_export
123+
# ^
124+
125+
126+
127+
# c.py
128+
4 | func(y)
129+
^
130+
Code Actions Results:
131+
# Title: Suppress UnknownName with ignore comment
132+
133+
## Before:
134+
135+
def func(x: int) -> int:
136+
pass
137+
func(y)
138+
# ^
139+
## After:
140+
141+
def func(x: int) -> int:
142+
pass
143+
# pyrefly: ignore[unknown-name]
144+
func(y)
145+
# ^
146+
147+
148+
149+
# d.py
150+
2 | def func(x: int) -> int:
151+
^
152+
Code Actions Results:
153+
# Title: Suppress BadReturn with ignore comment
154+
155+
## Before:
156+
157+
def func(x: int) -> int:
158+
# ^
159+
pass
160+
func(y)
161+
## After:
162+
163+
# pyrefly: ignore[bad-return]
164+
def func(x: int) -> int:
165+
# ^
166+
pass
167+
func(y)
168+
"#
169+
.trim(),
170+
report.trim()
171+
);
172+
}
173+
48174
#[test]
49175
fn basic_test() {
50176
let report = get_batched_lsp_operations_report_allow_error(
@@ -54,7 +180,7 @@ fn basic_test() {
54180
("c", "my_export\n# ^"),
55181
("d", "my_export = 3\n"),
56182
],
57-
get_test_report,
183+
get_test_report_insert_import,
58184
);
59185
// We should suggest imports from both a and d, but not b.
60186
assert_eq!(
@@ -102,7 +228,7 @@ fn insertion_test_comments() {
102228
("a", "my_export = 3\n"),
103229
("b", "# i am a comment\nmy_export\n# ^"),
104230
],
105-
get_test_report,
231+
get_test_report_insert_import,
106232
);
107233
// We will insert the import after a comment, which might not be the intended target of the
108234
// comment. This is not ideal, but we cannot do much better without sophisticated comment
@@ -139,7 +265,7 @@ fn insertion_test_existing_imports() {
139265
("a", "my_export = 3\n"),
140266
("b", "from typing import List\nmy_export\n# ^"),
141267
],
142-
get_test_report,
268+
get_test_report_insert_import,
143269
);
144270
// Insert before all imports. This might not adhere to existing import sorting code style.
145271
assert_eq!(
@@ -174,7 +300,7 @@ fn insertion_test_duplicate_imports() {
174300
("a", "my_export = 3\nanother_thing = 4"),
175301
("b", "from a import another_thing\nmy_export\n# ^"),
176302
],
177-
get_test_report,
303+
get_test_report_insert_import,
178304
);
179305
// The insertion won't attempt to merge imports from the same module.
180306
// It's not illegal, but it would be nice if we do merge.

0 commit comments

Comments
 (0)