Skip to content

Commit 09ed5f7

Browse files
committed
[ty] Add code action to suppress diagnostic on current line
1 parent 88902c8 commit 09ed5f7

File tree

4 files changed

+156
-45
lines changed

4 files changed

+156
-45
lines changed

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
@@ -10,6 +10,7 @@ use ruff_db::{files::File, parsed::parsed_module, source::source_text};
1010
use ruff_diagnostics::{Edit, Fix};
1111
use ruff_python_parser::TokenKind;
1212
use ruff_python_trivia::Cursor;
13+
use ruff_source_file::LineRanges;
1314
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
1415
use smallvec::{SmallVec, smallvec};
1516
use std::error::Error;
@@ -384,6 +385,60 @@ fn check_unused_suppressions(context: &mut CheckSuppressionsContext) {
384385
}
385386
}
386387

388+
/// Creates a fix for adding a suppression comment to suppress `lint` for `range`.
389+
///
390+
/// The fix prefers adding the code to an existing `ty: ignore[]` comment over
391+
/// adding a new suppression comment.
392+
pub fn create_suppression_fix(db: &dyn Db, file: File, id: LintId, range: TextRange) -> Fix {
393+
let suppressions = suppressions(db, file);
394+
let source = source_text(db, file);
395+
396+
let mut existing_suppressions = suppressions.line_suppressions(range).filter(|suppression| {
397+
matches!(
398+
suppression.target,
399+
SuppressionTarget::Lint(_) | SuppressionTarget::Empty,
400+
)
401+
});
402+
403+
// If there's an existing `ty: ignore[]` comment, append the code to it instead of creating a new suppression comment.
404+
if let Some(existing) = existing_suppressions.next() {
405+
let comment_text = &source[existing.comment_range];
406+
if let Some(before_closing_paren) = comment_text.trim_end().strip_suffix(']') {
407+
let up_to_last_code = before_closing_paren.trim_end();
408+
409+
let insertion = if up_to_last_code.ends_with(',') {
410+
format!(" {id}", id = id.name())
411+
} else {
412+
format!(", {id}", id = id.name())
413+
};
414+
415+
let relative_offset_from_end = comment_text.text_len() - up_to_last_code.text_len();
416+
417+
return Fix::safe_edit(Edit::insertion(
418+
insertion,
419+
existing.comment_range.end() - relative_offset_from_end,
420+
));
421+
}
422+
}
423+
424+
// Always insert a new suppression at the end of the range to avoid having to deal with multiline strings
425+
// etc.
426+
let line_end = source.line_end(range.end());
427+
let up_to_line_end = &source[..line_end.to_usize()];
428+
let up_to_first_content = up_to_line_end.trim_end();
429+
let trailing_whitespace_len = up_to_line_end.text_len() - up_to_first_content.text_len();
430+
431+
let insertion = format!(" # ty:ignore[{id}]", id = id.name());
432+
433+
Fix::safe_edit(if trailing_whitespace_len == TextSize::ZERO {
434+
Edit::insertion(insertion, line_end)
435+
} else {
436+
// `expr # fmt: off<trailing_whitespace>`
437+
// Trim the trailing whitespace
438+
Edit::replacement(insertion, line_end - trailing_whitespace_len, line_end)
439+
})
440+
}
441+
387442
struct CheckSuppressionsContext<'a> {
388443
db: &'a dyn Db,
389444
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: 72 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
use std::borrow::Cow;
22

3-
use lsp_types::{self as types, Url, request as req};
3+
use lsp_types::{self as types, NumberOrString, Url, request as req};
44
use ty_project::ProjectDatabase;
5+
use ty_python_semantic::{Db as _, create_suppression_fix};
56
use types::{CodeActionKind, CodeActionOrCommand};
67

78
use crate::DIAGNOSTIC_NAME;
9+
use crate::document::RangeExt;
810
use crate::server::Result;
911
use crate::server::api::RequestHandler;
10-
use crate::server::api::diagnostics::DiagnosticData;
12+
use crate::server::api::diagnostics::{DiagnosticData, fix_to_lsp_edits};
1113
use crate::server::api::traits::{BackgroundDocumentRequestHandler, RetriableRequestHandler};
1214
use crate::session::DocumentSnapshot;
1315
use crate::session::client::Client;
@@ -24,45 +26,89 @@ impl BackgroundDocumentRequestHandler for CodeActionRequestHandler {
2426
}
2527

2628
fn run_with_snapshot(
27-
_db: &ProjectDatabase,
28-
_snapshot: &DocumentSnapshot,
29+
db: &ProjectDatabase,
30+
snapshot: &DocumentSnapshot,
2931
_client: &Client,
3032
params: types::CodeActionParams,
3133
) -> Result<Option<types::CodeActionResponse>> {
3234
let diagnostics = params.context.diagnostics;
3335

3436
let mut actions = Vec::new();
37+
let lint_registry = db.lint_registry();
38+
let file = snapshot.to_notebook_or_file(db);
39+
40+
tracing::debug!("code actions with diagnostics: {:#?}", diagnostics);
3541

3642
for mut diagnostic in diagnostics.into_iter().filter(|diagnostic| {
3743
diagnostic.source.as_deref() == Some(DIAGNOSTIC_NAME)
3844
&& range_intersect(&diagnostic.range, &params.range)
3945
}) {
40-
let Some(data) = diagnostic.data.take() else {
41-
continue;
46+
tracing::debug!("Lint code: {:?}", diagnostic.code.as_ref());
47+
48+
let add_suppression_fix = if let Some(NumberOrString::String(code)) =
49+
diagnostic.code.as_ref()
50+
&& let Ok(lint) = lint_registry.get(code)
51+
&& let Some(file) = file
52+
&& let Some(range) = diagnostic.range.to_text_range(
53+
db,
54+
file,
55+
&params.text_document.uri,
56+
snapshot.encoding(),
57+
) {
58+
tracing::debug!("Creating suppression fix");
59+
let fix = create_suppression_fix(db, file, lint, range);
60+
tracing::debug!("Suppression fix: {fix:#?}");
61+
Some((fix, lint))
62+
} else {
63+
None
4264
};
4365

44-
let data: DiagnosticData = match serde_json::from_value(data) {
45-
Ok(data) => data,
46-
Err(err) => {
47-
tracing::warn!("Failed to deserialize diagnostic data: {err}");
48-
continue;
49-
}
66+
// If the diagnostic has a fix, add it as a code action.
67+
if let Some(data) = diagnostic.data.take() {
68+
let data: DiagnosticData = match serde_json::from_value(data) {
69+
Ok(data) => data,
70+
Err(err) => {
71+
tracing::warn!("Failed to deserialize diagnostic data: {err}");
72+
continue;
73+
}
74+
};
75+
76+
actions.push(CodeActionOrCommand::CodeAction(lsp_types::CodeAction {
77+
title: data.fix_title,
78+
kind: Some(CodeActionKind::QUICKFIX),
79+
diagnostics: Some(vec![diagnostic]),
80+
edit: Some(lsp_types::WorkspaceEdit {
81+
changes: Some(data.edits),
82+
document_changes: None,
83+
change_annotations: None,
84+
}),
85+
is_preferred: Some(true),
86+
command: None,
87+
disabled: None,
88+
data: None,
89+
}));
5090
};
5191

52-
actions.push(CodeActionOrCommand::CodeAction(lsp_types::CodeAction {
53-
title: data.fix_title,
54-
kind: Some(CodeActionKind::QUICKFIX),
55-
diagnostics: Some(vec![diagnostic]),
56-
edit: Some(lsp_types::WorkspaceEdit {
57-
changes: Some(data.edits),
58-
document_changes: None,
59-
change_annotations: None,
60-
}),
61-
is_preferred: Some(true),
62-
command: None,
63-
disabled: None,
64-
data: None,
65-
}));
92+
if let Some((suppression_fix, lint)) = add_suppression_fix
93+
&& let Some(file) = file
94+
&& let Some(suppression_edits) =
95+
fix_to_lsp_edits(db, &suppression_fix, file, snapshot.encoding())
96+
{
97+
actions.push(CodeActionOrCommand::CodeAction(lsp_types::CodeAction {
98+
title: format!("Ignore '{}' for this line", lint.name()),
99+
kind: Some(CodeActionKind::QUICKFIX),
100+
diagnostics: None,
101+
edit: Some(lsp_types::WorkspaceEdit {
102+
changes: Some(suppression_edits),
103+
document_changes: None,
104+
change_annotations: None,
105+
}),
106+
is_preferred: Some(false),
107+
command: None,
108+
disabled: None,
109+
data: None,
110+
}))
111+
}
66112
}
67113

68114
if actions.is_empty() {

0 commit comments

Comments
 (0)