Skip to content

Commit 517770c

Browse files
committed
[ty] Add code action to suppress diagnostic on current line
1 parent b5b4917 commit 517770c

14 files changed

+458
-48
lines changed

.config/nextest.toml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ serial = { max-threads = 1 }
77
filter = 'binary(file_watching)'
88
test-group = 'serial'
99

10-
[[profile.default.overrides]]
11-
filter = 'binary(e2e)'
12-
test-group = 'serial'
13-
1410
[profile.ci]
1511
# Print out output for failing tests as soon as they fail, and also at the end
1612
# of the run (for easy scrollability).

crates/ruff_diagnostics/src/fix.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ impl Fix {
149149
&self.edits
150150
}
151151

152+
pub fn into_edits(self) -> Vec<Edit> {
153+
self.edits
154+
}
155+
152156
/// Return the [`Applicability`] of the [`Fix`].
153157
pub fn applicability(&self) -> Applicability {
154158
self.applicability

crates/ty/docs/rules.md

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ty_ide/src/code_action.rs

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
use crate::{completion, find_node::covering_node};
2+
23
use ruff_db::{files::File, parsed::parsed_module};
34
use ruff_diagnostics::Edit;
45
use ruff_text_size::TextRange;
56
use ty_project::Db;
7+
use ty_python_semantic::create_suppression_fix;
68
use ty_python_semantic::types::UNRESOLVED_REFERENCE;
79

810
/// A `QuickFix` Code Action
@@ -18,26 +20,45 @@ pub fn code_actions(
1820
file: File,
1921
diagnostic_range: TextRange,
2022
diagnostic_id: &str,
21-
) -> Option<Vec<QuickFix>> {
23+
) -> Vec<QuickFix> {
2224
let registry = db.lint_registry();
2325
let Ok(lint_id) = registry.get(diagnostic_id) else {
24-
return None;
26+
return Vec::new();
2527
};
26-
if lint_id.name() == UNRESOLVED_REFERENCE.name() {
27-
let parsed = parsed_module(db, file).load(db);
28-
let node = covering_node(parsed.syntax().into(), diagnostic_range).node();
29-
let symbol = &node.expr_name()?.id;
3028

31-
let fixes = completion::missing_imports(db, file, &parsed, symbol, node)
29+
let mut actions = Vec::new();
30+
31+
if lint_id.name() == UNRESOLVED_REFERENCE.name()
32+
&& let Some(import_quick_fix) = create_import_symbol_quick_fix(db, file, diagnostic_range)
33+
{
34+
actions.extend(import_quick_fix)
35+
}
36+
37+
actions.push(QuickFix {
38+
title: format!("Ignore '{}' for this line", lint_id.name()),
39+
edits: create_suppression_fix(db, file, lint_id, diagnostic_range).into_edits(),
40+
preferred: false,
41+
});
42+
43+
actions
44+
}
45+
46+
fn create_import_symbol_quick_fix(
47+
db: &dyn Db,
48+
file: File,
49+
diagnostic_range: TextRange,
50+
) -> Option<impl Iterator<Item = QuickFix>> {
51+
let parsed = parsed_module(db, file).load(db);
52+
let node = covering_node(parsed.syntax().into(), diagnostic_range).node();
53+
let symbol = &node.expr_name()?.id;
54+
55+
Some(
56+
completion::missing_imports(db, file, &parsed, symbol, node)
3257
.into_iter()
3358
.map(|import| QuickFix {
3459
title: import.label,
3560
edits: vec![import.edit],
3661
preferred: true,
37-
})
38-
.collect();
39-
Some(fixes)
40-
} else {
41-
None
42-
}
62+
}),
63+
)
4364
}

crates/ty_python_semantic/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ pub use semantic_model::{
2525
Completion, HasDefinition, HasType, MemberDefinition, NameKind, SemanticModel,
2626
};
2727
pub use site_packages::{PythonEnvironment, SitePackagesPaths, SysPrefixPathOrigin};
28+
pub use suppression::create_suppression_fix;
2829
pub use types::DisplaySettings;
2930
pub use types::ide_support::{
3031
ImportAliasResolution, ResolvedDefinition, definitions_for_attribute, definitions_for_bin_op,

crates/ty_python_semantic/src/suppression.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use ruff_db::{files::File, parsed::parsed_module, source::source_text};
1717
use ruff_diagnostics::{Edit, Fix};
1818
use ruff_python_parser::TokenKind;
1919
use ruff_python_trivia::Cursor;
20+
use ruff_source_file::LineRanges;
2021
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
2122

2223
declare_lint! {
@@ -375,6 +376,60 @@ fn check_unused_suppressions(context: &mut CheckSuppressionsContext) {
375376
}
376377
}
377378

379+
/// Creates a fix for adding a suppression comment to suppress `lint` for `range`.
380+
///
381+
/// The fix prefers adding the code to an existing `ty: ignore[]` comment over
382+
/// adding a new suppression comment.
383+
pub fn create_suppression_fix(db: &dyn Db, file: File, id: LintId, range: TextRange) -> Fix {
384+
let suppressions = suppressions(db, file);
385+
let source = source_text(db, file);
386+
387+
let mut existing_suppressions = suppressions.line_suppressions(range).filter(|suppression| {
388+
matches!(
389+
suppression.target,
390+
SuppressionTarget::Lint(_) | SuppressionTarget::Empty,
391+
)
392+
});
393+
394+
// If there's an existing `ty: ignore[]` comment, append the code to it instead of creating a new suppression comment.
395+
if let Some(existing) = existing_suppressions.next() {
396+
let comment_text = &source[existing.comment_range];
397+
if let Some(before_closing_paren) = comment_text.trim_end().strip_suffix(']') {
398+
let up_to_last_code = before_closing_paren.trim_end();
399+
400+
let insertion = if up_to_last_code.ends_with(',') {
401+
format!(" {id}", id = id.name())
402+
} else {
403+
format!(", {id}", id = id.name())
404+
};
405+
406+
let relative_offset_from_end = comment_text.text_len() - up_to_last_code.text_len();
407+
408+
return Fix::safe_edit(Edit::insertion(
409+
insertion,
410+
existing.comment_range.end() - relative_offset_from_end,
411+
));
412+
}
413+
}
414+
415+
// Always insert a new suppression at the end of the range to avoid having to deal with multiline strings
416+
// etc.
417+
let line_end = source.line_end(range.end());
418+
let up_to_line_end = &source[..line_end.to_usize()];
419+
let up_to_first_content = up_to_line_end.trim_end();
420+
let trailing_whitespace_len = up_to_line_end.text_len() - up_to_first_content.text_len();
421+
422+
let insertion = format!(" # ty:ignore[{id}]", id = id.name());
423+
424+
Fix::safe_edit(if trailing_whitespace_len == TextSize::ZERO {
425+
Edit::insertion(insertion, line_end)
426+
} else {
427+
// `expr # fmt: off<trailing_whitespace>`
428+
// Trim the trailing whitespace
429+
Edit::replacement(insertion, line_end - trailing_whitespace_len, line_end)
430+
})
431+
}
432+
378433
struct CheckSuppressionsContext<'a> {
379434
db: &'a dyn Db,
380435
file: File,

crates/ty_server/src/server/api/diagnostics.rs

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use lsp_types::{
66
CodeDescription, Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DiagnosticTag,
77
NumberOrString, PublishDiagnosticsParams, Url,
88
};
9-
use ruff_diagnostics::Applicability;
9+
use ruff_diagnostics::{Applicability, Fix};
1010
use ruff_text_size::Ranged;
1111
use rustc_hash::FxHashMap;
1212

@@ -428,29 +428,38 @@ impl DiagnosticData {
428428
let primary_span = diagnostic.primary_span()?;
429429
let file = primary_span.expect_ty_file();
430430

431-
let mut lsp_edits: HashMap<Url, Vec<lsp_types::TextEdit>> = HashMap::new();
432-
433-
for edit in fix.edits() {
434-
let location = edit
435-
.range()
436-
.to_lsp_range(db, file, encoding)?
437-
.to_location()?;
438-
439-
lsp_edits
440-
.entry(location.uri)
441-
.or_default()
442-
.push(lsp_types::TextEdit {
443-
range: location.range,
444-
new_text: edit.content().unwrap_or_default().to_string(),
445-
});
446-
}
447-
448431
Some(Self {
449432
fix_title: diagnostic
450433
.first_help_text()
451434
.map(ToString::to_string)
452435
.unwrap_or_else(|| format!("Fix {}", diagnostic.id())),
453-
edits: lsp_edits,
436+
edits: fix_to_lsp_edits(db, fix, file, encoding)?,
454437
})
455438
}
456439
}
440+
441+
pub(super) fn fix_to_lsp_edits(
442+
db: &dyn Db,
443+
fix: &Fix,
444+
file: File,
445+
encoding: PositionEncoding,
446+
) -> Option<HashMap<Url, Vec<lsp_types::TextEdit>>> {
447+
let mut lsp_edits: HashMap<Url, Vec<lsp_types::TextEdit>> = HashMap::new();
448+
449+
for edit in fix.edits() {
450+
let location = edit
451+
.range()
452+
.to_lsp_range(db, file, encoding)?
453+
.to_location()?;
454+
455+
lsp_edits
456+
.entry(location.uri)
457+
.or_default()
458+
.push(lsp_types::TextEdit {
459+
range: location.range,
460+
new_text: edit.content().unwrap_or_default().to_string(),
461+
});
462+
}
463+
464+
Some(lsp_edits)
465+
}

crates/ty_server/src/server/api/requests/code_action.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,8 @@ impl BackgroundDocumentRequestHandler for CodeActionRequestHandler {
8282
let encoding = snapshot.encoding();
8383
if let Some(NumberOrString::String(diagnostic_id)) = &diagnostic.code
8484
&& let Some(range) = diagnostic.range.to_text_range(db, file, url, encoding)
85-
&& let Some(fixes) = code_actions(db, file, range, diagnostic_id)
8685
{
87-
for action in fixes {
86+
for action in code_actions(db, file, range, diagnostic_id) {
8887
actions.push(CodeActionOrCommand::CodeAction(lsp_types::CodeAction {
8988
title: action.title,
9089
kind: Some(CodeActionKind::QUICKFIX),

crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action.snap

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,54 @@ expression: code_actions
5151
}
5252
},
5353
"isPreferred": true
54+
},
55+
{
56+
"title": "Ignore 'unused-ignore-comment' for this line",
57+
"kind": "quickfix",
58+
"diagnostics": [
59+
{
60+
"range": {
61+
"start": {
62+
"line": 0,
63+
"character": 12
64+
},
65+
"end": {
66+
"line": 0,
67+
"character": 42
68+
}
69+
},
70+
"severity": 2,
71+
"code": "unused-ignore-comment",
72+
"codeDescription": {
73+
"href": "https://ty.dev/rules#unused-ignore-comment"
74+
},
75+
"source": "ty",
76+
"message": "Unused `ty: ignore` directive",
77+
"relatedInformation": [],
78+
"tags": [
79+
1
80+
]
81+
}
82+
],
83+
"edit": {
84+
"changes": {
85+
"file://<temp_dir>/src/foo.py": [
86+
{
87+
"range": {
88+
"start": {
89+
"line": 0,
90+
"character": 41
91+
},
92+
"end": {
93+
"line": 0,
94+
"character": 41
95+
}
96+
},
97+
"newText": ", unused-ignore-comment"
98+
}
99+
]
100+
}
101+
},
102+
"isPreferred": false
54103
}
55104
]

0 commit comments

Comments
 (0)