From d40590c8f963ed23c05b241ec6a574af7be24cd6 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Sat, 29 Nov 2025 15:41:54 +0100 Subject: [PATCH] [ty] Add code action to ignore diagnostic on the current line (#21595) --- .config/nextest.toml | 4 - crates/ruff_diagnostics/src/fix.rs | 4 + crates/ty_ide/src/code_action.rs | 501 +++++++++++++++++- .../mdtest/suppressions/type_ignore.md | 44 ++ crates/ty_python_semantic/src/lib.rs | 1 + crates/ty_python_semantic/src/suppression.rs | 71 +++ .../src/server/api/requests/code_action.rs | 3 +- .../e2e__code_actions__code_action.snap | 49 ++ ...action_attribute_access_on_unimported.snap | 49 +- ..._possible_missing_submodule_attribute.snap | 49 +- ...ions__code_action_undefined_decorator.snap | 46 ++ ...code_action_undefined_reference_multi.snap | 46 ++ crates/ty_test/src/assertion.rs | 9 + crates/ty_wasm/src/lib.rs | 24 +- 14 files changed, 868 insertions(+), 32 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 3730656326..f2537ce580 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -7,10 +7,6 @@ serial = { max-threads = 1 } filter = 'binary(file_watching)' test-group = 'serial' -[[profile.default.overrides]] -filter = 'binary(e2e)' -test-group = 'serial' - [profile.ci] # Print out output for failing tests as soon as they fail, and also at the end # of the run (for easy scrollability). diff --git a/crates/ruff_diagnostics/src/fix.rs b/crates/ruff_diagnostics/src/fix.rs index 79cd3e6dff..937d4cd22c 100644 --- a/crates/ruff_diagnostics/src/fix.rs +++ b/crates/ruff_diagnostics/src/fix.rs @@ -149,6 +149,10 @@ impl Fix { &self.edits } + pub fn into_edits(self) -> Vec { + self.edits + } + /// Return the [`Applicability`] of the [`Fix`]. pub fn applicability(&self) -> Applicability { self.applicability diff --git a/crates/ty_ide/src/code_action.rs b/crates/ty_ide/src/code_action.rs index 947c7481ef..1a02389735 100644 --- a/crates/ty_ide/src/code_action.rs +++ b/crates/ty_ide/src/code_action.rs @@ -1,8 +1,10 @@ use crate::{completion, find_node::covering_node}; + use ruff_db::{files::File, parsed::parsed_module}; use ruff_diagnostics::Edit; use ruff_text_size::TextRange; use ty_project::Db; +use ty_python_semantic::create_suppression_fix; use ty_python_semantic::types::UNRESOLVED_REFERENCE; /// A `QuickFix` Code Action @@ -18,26 +20,501 @@ pub fn code_actions( file: File, diagnostic_range: TextRange, diagnostic_id: &str, -) -> Option> { +) -> Vec { let registry = db.lint_registry(); let Ok(lint_id) = registry.get(diagnostic_id) else { - return None; + return Vec::new(); }; - if lint_id.name() == UNRESOLVED_REFERENCE.name() { - let parsed = parsed_module(db, file).load(db); - let node = covering_node(parsed.syntax().into(), diagnostic_range).node(); - let symbol = &node.expr_name()?.id; - let fixes = completion::missing_imports(db, file, &parsed, symbol, node) + let mut actions = Vec::new(); + + if lint_id.name() == UNRESOLVED_REFERENCE.name() + && let Some(import_quick_fix) = create_import_symbol_quick_fix(db, file, diagnostic_range) + { + actions.extend(import_quick_fix); + } + + actions.push(QuickFix { + title: format!("Ignore '{}' for this line", lint_id.name()), + edits: create_suppression_fix(db, file, lint_id, diagnostic_range).into_edits(), + preferred: false, + }); + + actions +} + +fn create_import_symbol_quick_fix( + db: &dyn Db, + file: File, + diagnostic_range: TextRange, +) -> Option> { + let parsed = parsed_module(db, file).load(db); + let node = covering_node(parsed.syntax().into(), diagnostic_range).node(); + let symbol = &node.expr_name()?.id; + + Some( + completion::missing_imports(db, file, &parsed, symbol, node) .into_iter() .map(|import| QuickFix { title: import.label, edits: vec![import.edit], preferred: true, - }) - .collect(); - Some(fixes) - } else { - None + }), + ) +} + +#[cfg(test)] +mod tests { + + use crate::code_actions; + + use insta::assert_snapshot; + use ruff_db::{ + diagnostic::{ + Annotation, Diagnostic, DiagnosticFormat, DiagnosticId, DisplayDiagnosticConfig, + LintName, Span, SubDiagnostic, + }, + files::{File, system_path_to_file}, + system::{DbWithWritableSystem, SystemPathBuf}, + }; + use ruff_diagnostics::Fix; + use ruff_text_size::{TextRange, TextSize}; + use ty_project::ProjectMetadata; + use ty_python_semantic::{lint::LintMetadata, types::UNRESOLVED_REFERENCE}; + + #[test] + fn add_ignore() { + let test = CodeActionTest::with_source(r#"b = a / 10"#); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:1:5 + | + 1 | b = a / 10 + | ^ + | + - b = a / 10 + 1 + b = a / 10 # ty:ignore[unresolved-reference] + "); + } + + #[test] + fn add_ignore_existing_comment() { + let test = CodeActionTest::with_source(r#"b = a / 10 # fmt: off"#); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:1:5 + | + 1 | b = a / 10 # fmt: off + | ^ + | + - b = a / 10 # fmt: off + 1 + b = a / 10 # fmt: off # ty:ignore[unresolved-reference] + "); + } + + #[test] + fn add_ignore_trailing_whitespace() { + let test = CodeActionTest::with_source(r#"b = a / 10 "#); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:1:5 + | + 1 | b = a / 10 + | ^ + | + - b = a / 10 + 1 + b = a / 10 # ty:ignore[unresolved-reference] + "); + } + + #[test] + fn add_code_existing_ignore() { + let test = CodeActionTest::with_source( + r#" + b = a / 0 # ty:ignore[division-by-zero] + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:2:17 + | + 2 | b = a / 0 # ty:ignore[division-by-zero] + | ^ + | + 1 | + - b = a / 0 # ty:ignore[division-by-zero] + 2 + b = a / 0 # ty:ignore[division-by-zero, unresolved-reference] + 3 | + "); + } + + #[test] + fn add_code_existing_ignore_trailing_comma() { + let test = CodeActionTest::with_source( + r#" + b = a / 0 # ty:ignore[division-by-zero,] + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:2:17 + | + 2 | b = a / 0 # ty:ignore[division-by-zero,] + | ^ + | + 1 | + - b = a / 0 # ty:ignore[division-by-zero,] + 2 + b = a / 0 # ty:ignore[division-by-zero, unresolved-reference] + 3 | + "); + } + + #[test] + fn add_code_existing_ignore_trailing_whitespace() { + let test = CodeActionTest::with_source( + r#" + b = a / 0 # ty:ignore[division-by-zero ] + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:2:17 + | + 2 | b = a / 0 # ty:ignore[division-by-zero ] + | ^ + | + 1 | + - b = a / 0 # ty:ignore[division-by-zero ] + 2 + b = a / 0 # ty:ignore[division-by-zero, unresolved-reference ] + 3 | + "); + } + + #[test] + fn add_code_existing_ignore_with_reason() { + let test = CodeActionTest::with_source( + r#" + b = a / 0 # ty:ignore[division-by-zero] some explanation + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:2:17 + | + 2 | b = a / 0 # ty:ignore[division-by-zero] some explanation + | ^ + | + 1 | + - b = a / 0 # ty:ignore[division-by-zero] some explanation + 2 + b = a / 0 # ty:ignore[division-by-zero] some explanation # ty:ignore[unresolved-reference] + 3 | + "); + } + + #[test] + fn add_code_existing_ignore_start_line() { + let test = CodeActionTest::with_source( + r#" + b = ( + a # ty:ignore[division-by-zero] + / + 0 + ) + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:3:21 + | + 2 | b = ( + 3 | / a # ty:ignore[division-by-zero] + 4 | | / + 5 | | 0 + | |_____________________^ + 6 | ) + | + 1 | + 2 | b = ( + - a # ty:ignore[division-by-zero] + 3 + a # ty:ignore[division-by-zero, unresolved-reference] + 4 | / + 5 | 0 + 6 | ) + "); + } + + #[test] + fn add_code_existing_ignore_end_line() { + let test = CodeActionTest::with_source( + r#" + b = ( + a + / + 0 # ty:ignore[division-by-zero] + ) + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:3:21 + | + 2 | b = ( + 3 | / a + 4 | | / + 5 | | 0 # ty:ignore[division-by-zero] + | |_____________________^ + 6 | ) + | + 2 | b = ( + 3 | a + 4 | / + - 0 # ty:ignore[division-by-zero] + 5 + 0 # ty:ignore[division-by-zero, unresolved-reference] + 6 | ) + 7 | + "); + } + + #[test] + fn add_code_existing_ignores() { + let test = CodeActionTest::with_source( + r#" + b = ( + a # ty:ignore[division-by-zero] + / + 0 # ty:ignore[division-by-zero] + ) + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:3:21 + | + 2 | b = ( + 3 | / a # ty:ignore[division-by-zero] + 4 | | / + 5 | | 0 # ty:ignore[division-by-zero] + | |_____________________^ + 6 | ) + | + 1 | + 2 | b = ( + - a # ty:ignore[division-by-zero] + 3 + a # ty:ignore[division-by-zero, unresolved-reference] + 4 | / + 5 | 0 # ty:ignore[division-by-zero] + 6 | ) + "); + } + + #[test] + fn add_code_interpolated_string() { + let test = CodeActionTest::with_source( + r#" + b = f""" + {a} + more text + """ + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r#" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:3:18 + | + 2 | b = f""" + 3 | {a} + | ^ + 4 | more text + 5 | """ + | + 2 | b = f""" + 3 | {a} + 4 | more text + - """ + 5 + """ # ty:ignore[unresolved-reference] + 6 | + "#); + } + + #[test] + fn add_code_multiline_interpolation() { + let test = CodeActionTest::with_source( + r#" + b = f""" + { + a + } + more text + """ + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r#" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:4:17 + | + 2 | b = f""" + 3 | { + 4 | a + | ^ + 5 | } + 6 | more text + | + 1 | + 2 | b = f""" + 3 | { + - a + 4 + a # ty:ignore[unresolved-reference] + 5 | } + 6 | more text + 7 | """ + "#); + } + + #[test] + fn add_code_followed_by_multiline_string() { + let test = CodeActionTest::with_source( + r#" + b = a + """ + more text + """ + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r#" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:2:17 + | + 2 | b = a + """ + | ^ + 3 | more text + 4 | """ + | + 1 | + 2 | b = a + """ + 3 | more text + - """ + 4 + """ # ty:ignore[unresolved-reference] + 5 | + "#); + } + + #[test] + fn add_code_followed_by_continuation() { + let test = CodeActionTest::with_source( + r#" + b = a \ + + "test" + "#, + ); + + assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r#" + info[code-action]: Ignore 'unresolved-reference' for this line + --> main.py:2:17 + | + 2 | b = a \ + | ^ + 3 | + "test" + | + 1 | + 2 | b = a \ + - + "test" + 3 + + "test" # ty:ignore[unresolved-reference] + 4 | + "#); + } + + pub(super) struct CodeActionTest { + pub(super) db: ty_project::TestDb, + pub(super) file: File, + pub(super) diagnostic_range: TextRange, + } + + impl CodeActionTest { + pub(super) fn with_source(source: &str) -> Self { + let mut db = ty_project::TestDb::new(ProjectMetadata::new( + "test".into(), + SystemPathBuf::from("/"), + )); + + db.init_program().unwrap(); + + let mut cleansed = source.to_string(); + + let start = cleansed + .find("") + .expect("source text should contain a `` marker"); + cleansed.replace_range(start..start + "".len(), ""); + + let end = cleansed + .find("") + .expect("source text should contain a `` marker"); + + cleansed.replace_range(end..end + "".len(), ""); + + assert!(start <= end, " marker should be before marker"); + + db.write_file("main.py", cleansed) + .expect("write to memory file system to be successful"); + + let file = system_path_to_file(&db, "main.py").expect("newly written file to existing"); + + Self { + db, + file, + diagnostic_range: TextRange::new( + TextSize::try_from(start).unwrap(), + TextSize::try_from(end).unwrap(), + ), + } + } + + pub(super) fn code_actions(&self, lint: &'static LintMetadata) -> String { + use std::fmt::Write; + + let mut buf = String::new(); + + let config = DisplayDiagnosticConfig::default() + .color(false) + .show_fix_diff(true) + .format(DiagnosticFormat::Full); + + for mut action in code_actions(&self.db, self.file, self.diagnostic_range, &lint.name) { + let mut diagnostic = Diagnostic::new( + DiagnosticId::Lint(LintName::of("code-action")), + ruff_db::diagnostic::Severity::Info, + action.title, + ); + + diagnostic.annotate(Annotation::primary( + Span::from(self.file).with_range(self.diagnostic_range), + )); + + if action.preferred { + diagnostic.sub(SubDiagnostic::new( + ruff_db::diagnostic::SubDiagnosticSeverity::Help, + "This is a preferred code action", + )); + } + + let first_edit = action.edits.remove(0); + diagnostic.set_fix(Fix::safe_edits(first_edit, action.edits)); + + write!(buf, "{}", diagnostic.display(&self.db, &config)).unwrap(); + } + + buf + } } } diff --git a/crates/ty_python_semantic/resources/mdtest/suppressions/type_ignore.md b/crates/ty_python_semantic/resources/mdtest/suppressions/type_ignore.md index 60f0f349de..1368873182 100644 --- a/crates/ty_python_semantic/resources/mdtest/suppressions/type_ignore.md +++ b/crates/ty_python_semantic/resources/mdtest/suppressions/type_ignore.md @@ -85,6 +85,50 @@ a = test \ + 2 # type: ignore ``` +## Interpolated strings + +```toml +[environment] +python-version = "3.14" +``` + +Suppressions for expressions within interpolated strings can be placed after the interpolated string +if it's a single-line interpolation. + +```py +a = f""" +{test} +""" # type: ignore +``` + +For multiline-interpolation, put the ignore comment on the expression's start or end line: + +```py +a = f""" +{ + 10 / # type: ignore + 0 +} +""" + +a = f""" +{ + 10 / + 0 # type: ignore +} +""" +``` + +But not at the end of the f-string: + +```py +a = f""" +{ + 10 / 0 # error: [division-by-zero] +} +""" # error: [unused-ignore-comment] # type: ignore +``` + ## Codes Mypy supports `type: ignore[code]`. ty doesn't understand mypy's rule names. Therefore, ignore the diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs index 84f755416f..be50dc9b52 100644 --- a/crates/ty_python_semantic/src/lib.rs +++ b/crates/ty_python_semantic/src/lib.rs @@ -25,6 +25,7 @@ pub use semantic_model::{ Completion, HasDefinition, HasType, MemberDefinition, NameKind, SemanticModel, }; pub use site_packages::{PythonEnvironment, SitePackagesPaths, SysPrefixPathOrigin}; +pub use suppression::create_suppression_fix; pub use types::DisplaySettings; pub use types::ide_support::{ ImportAliasResolution, ResolvedDefinition, definitions_for_attribute, definitions_for_bin_op, diff --git a/crates/ty_python_semantic/src/suppression.rs b/crates/ty_python_semantic/src/suppression.rs index ab246c1b0a..c0524728a2 100644 --- a/crates/ty_python_semantic/src/suppression.rs +++ b/crates/ty_python_semantic/src/suppression.rs @@ -375,6 +375,77 @@ fn check_unused_suppressions(context: &mut CheckSuppressionsContext) { } } +/// Creates a fix for adding a suppression comment to suppress `lint` for `range`. +/// +/// The fix prefers adding the code to an existing `ty: ignore[]` comment over +/// adding a new suppression comment. +pub fn create_suppression_fix(db: &dyn Db, file: File, id: LintId, range: TextRange) -> Fix { + let suppressions = suppressions(db, file); + let source = source_text(db, file); + + let mut existing_suppressions = suppressions.line_suppressions(range).filter(|suppression| { + matches!( + suppression.target, + SuppressionTarget::Lint(_) | SuppressionTarget::Empty, + ) + }); + + // If there's an existing `ty: ignore[]` comment, append the code to it instead of creating a new suppression comment. + if let Some(existing) = existing_suppressions.next() { + let comment_text = &source[existing.comment_range]; + // Only add to the existing ignore comment if it has no reason. + if let Some(before_closing_paren) = comment_text.trim_end().strip_suffix(']') { + let up_to_last_code = before_closing_paren.trim_end(); + + let insertion = if up_to_last_code.ends_with(',') { + format!(" {id}", id = id.name()) + } else { + format!(", {id}", id = id.name()) + }; + + let relative_offset_from_end = comment_text.text_len() - up_to_last_code.text_len(); + + return Fix::safe_edit(Edit::insertion( + insertion, + existing.comment_range.end() - relative_offset_from_end, + )); + } + } + + // Always insert a new suppression at the end of the range to avoid having to deal with multiline strings + // etc. + let parsed = parsed_module(db, file).load(db); + let tokens_after = parsed.tokens().after(range.end()); + + // Same as for `line_end` when building up the `suppressions`: Ignore newlines + // in multiline-strings, inside f-strings, or after a line continuation because we can't + // place a comment on those lines. + let line_end = tokens_after + .iter() + .find(|token| { + matches!( + token.kind(), + TokenKind::Newline | TokenKind::NonLogicalNewline + ) + }) + .map(Ranged::start) + .unwrap_or(source.text_len()); + + let up_to_line_end = &source[..line_end.to_usize()]; + let up_to_first_content = up_to_line_end.trim_end(); + let trailing_whitespace_len = up_to_line_end.text_len() - up_to_first_content.text_len(); + + let insertion = format!(" # ty:ignore[{id}]", id = id.name()); + + Fix::safe_edit(if trailing_whitespace_len == TextSize::ZERO { + Edit::insertion(insertion, line_end) + } else { + // `expr # fmt: off` + // Trim the trailing whitespace + Edit::replacement(insertion, line_end - trailing_whitespace_len, line_end) + }) +} + struct CheckSuppressionsContext<'a> { db: &'a dyn Db, file: File, diff --git a/crates/ty_server/src/server/api/requests/code_action.rs b/crates/ty_server/src/server/api/requests/code_action.rs index 6fac0d46f4..77cb9dcd1c 100644 --- a/crates/ty_server/src/server/api/requests/code_action.rs +++ b/crates/ty_server/src/server/api/requests/code_action.rs @@ -82,9 +82,8 @@ impl BackgroundDocumentRequestHandler for CodeActionRequestHandler { let encoding = snapshot.encoding(); if let Some(NumberOrString::String(diagnostic_id)) = &diagnostic.code && let Some(range) = diagnostic.range.to_text_range(db, file, url, encoding) - && let Some(fixes) = code_actions(db, file, range, diagnostic_id) { - for action in fixes { + for action in code_actions(db, file, range, diagnostic_id) { actions.push(CodeActionOrCommand::CodeAction(lsp_types::CodeAction { title: action.title, kind: Some(CodeActionKind::QUICKFIX), diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action.snap b/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action.snap index af1fc0a86f..ae0da5c3ad 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action.snap @@ -51,5 +51,54 @@ expression: code_actions } }, "isPreferred": true + }, + { + "title": "Ignore 'unused-ignore-comment' for this line", + "kind": "quickfix", + "diagnostics": [ + { + "range": { + "start": { + "line": 0, + "character": 12 + }, + "end": { + "line": 0, + "character": 42 + } + }, + "severity": 2, + "code": "unused-ignore-comment", + "codeDescription": { + "href": "https://ty.dev/rules#unused-ignore-comment" + }, + "source": "ty", + "message": "Unused `ty: ignore` directive", + "relatedInformation": [], + "tags": [ + 1 + ] + } + ], + "edit": { + "changes": { + "file:///src/foo.py": [ + { + "range": { + "start": { + "line": 0, + "character": 41 + }, + "end": { + "line": 0, + "character": 41 + } + }, + "newText": ", unused-ignore-comment" + } + ] + } + }, + "isPreferred": false } ] diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_attribute_access_on_unimported.snap b/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_attribute_access_on_unimported.snap index 44f60e3707..c82a14bc8e 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_attribute_access_on_unimported.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_attribute_access_on_unimported.snap @@ -2,4 +2,51 @@ source: crates/ty_server/tests/e2e/code_actions.rs expression: code_actions --- -null +[ + { + "title": "Ignore 'unresolved-reference' for this line", + "kind": "quickfix", + "diagnostics": [ + { + "range": { + "start": { + "line": 0, + "character": 3 + }, + "end": { + "line": 0, + "character": 9 + } + }, + "severity": 1, + "code": "unresolved-reference", + "codeDescription": { + "href": "https://ty.dev/rules#unresolved-reference" + }, + "source": "ty", + "message": "Name `typing` used when not defined", + "relatedInformation": [] + } + ], + "edit": { + "changes": { + "file:///src/foo.py": [ + { + "range": { + "start": { + "line": 0, + "character": 24 + }, + "end": { + "line": 0, + "character": 24 + } + }, + "newText": " # ty:ignore[unresolved-reference]" + } + ] + } + }, + "isPreferred": false + } +] diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_possible_missing_submodule_attribute.snap b/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_possible_missing_submodule_attribute.snap index 44f60e3707..fe723d2fcc 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_possible_missing_submodule_attribute.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_possible_missing_submodule_attribute.snap @@ -2,4 +2,51 @@ source: crates/ty_server/tests/e2e/code_actions.rs expression: code_actions --- -null +[ + { + "title": "Ignore 'possibly-missing-attribute' for this line", + "kind": "quickfix", + "diagnostics": [ + { + "range": { + "start": { + "line": 1, + "character": 0 + }, + "end": { + "line": 1, + "character": 11 + } + }, + "severity": 2, + "code": "possibly-missing-attribute", + "codeDescription": { + "href": "https://ty.dev/rules#possibly-missing-attribute" + }, + "source": "ty", + "message": "Submodule `parser` may not be available as an attribute on module `html`", + "relatedInformation": [] + } + ], + "edit": { + "changes": { + "file:///src/foo.py": [ + { + "range": { + "start": { + "line": 1, + "character": 11 + }, + "end": { + "line": 1, + "character": 11 + } + }, + "newText": " # ty:ignore[possibly-missing-attribute]" + } + ] + } + }, + "isPreferred": false + } +] diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_undefined_decorator.snap b/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_undefined_decorator.snap index c60378f1d2..576c493622 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_undefined_decorator.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_undefined_decorator.snap @@ -94,5 +94,51 @@ expression: code_actions } }, "isPreferred": true + }, + { + "title": "Ignore 'unresolved-reference' for this line", + "kind": "quickfix", + "diagnostics": [ + { + "range": { + "start": { + "line": 1, + "character": 1 + }, + "end": { + "line": 1, + "character": 11 + } + }, + "severity": 1, + "code": "unresolved-reference", + "codeDescription": { + "href": "https://ty.dev/rules#unresolved-reference" + }, + "source": "ty", + "message": "Name `deprecated` used when not defined", + "relatedInformation": [] + } + ], + "edit": { + "changes": { + "file:///src/foo.py": [ + { + "range": { + "start": { + "line": 1, + "character": 28 + }, + "end": { + "line": 1, + "character": 28 + } + }, + "newText": " # ty:ignore[unresolved-reference]" + } + ] + } + }, + "isPreferred": false } ] diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_undefined_reference_multi.snap b/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_undefined_reference_multi.snap index 940dd3794f..d081e783de 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_undefined_reference_multi.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__code_actions__code_action_undefined_reference_multi.snap @@ -94,5 +94,51 @@ expression: code_actions } }, "isPreferred": true + }, + { + "title": "Ignore 'unresolved-reference' for this line", + "kind": "quickfix", + "diagnostics": [ + { + "range": { + "start": { + "line": 0, + "character": 3 + }, + "end": { + "line": 0, + "character": 10 + } + }, + "severity": 1, + "code": "unresolved-reference", + "codeDescription": { + "href": "https://ty.dev/rules#unresolved-reference" + }, + "source": "ty", + "message": "Name `Literal` used when not defined", + "relatedInformation": [] + } + ], + "edit": { + "changes": { + "file:///src/foo.py": [ + { + "range": { + "start": { + "line": 0, + "character": 17 + }, + "end": { + "line": 0, + "character": 17 + } + }, + "newText": " # ty:ignore[unresolved-reference]" + } + ] + } + }, + "isPreferred": false } ] diff --git a/crates/ty_test/src/assertion.rs b/crates/ty_test/src/assertion.rs index e5b7baaf6d..fba6f7c899 100644 --- a/crates/ty_test/src/assertion.rs +++ b/crates/ty_test/src/assertion.rs @@ -254,6 +254,15 @@ impl<'a> UnparsedAssertion<'a> { let comment = comment.trim().strip_prefix('#')?.trim(); let (keyword, body) = comment.split_once(':')?; let keyword = keyword.trim(); + + // Support other pragma comments coming after `error` or `revealed`, e.g. + // `# error: [code] # type: ignore` (nested pragma comments) + let body = if let Some((before_nested, _)) = body.split_once('#') { + before_nested + } else { + body + }; + let body = body.trim(); match keyword { diff --git a/crates/ty_wasm/src/lib.rs b/crates/ty_wasm/src/lib.rs index daa1f63a21..1ae085581c 100644 --- a/crates/ty_wasm/src/lib.rs +++ b/crates/ty_wasm/src/lib.rs @@ -573,16 +573,16 @@ impl Workspace { // This is only for actions that are messy to compute at the time of the diagnostic. // For instance, suggesting imports requires finding symbols for the entire project, // which is dubious when you're in the middle of resolving symbols. - if let Some(range) = diagnostic.inner.range() - && let Some(fixes) = ty_ide::code_actions( - &self.db, - file_id.file, - range, - diagnostic.inner.id().as_str(), - ) - { - for action in fixes { - actions.push(CodeAction { + if let Some(range) = diagnostic.inner.range() { + actions.extend( + ty_ide::code_actions( + &self.db, + file_id.file, + range, + diagnostic.inner.id().as_str(), + ) + .into_iter() + .map(|action| CodeAction { title: action.title, preferred: action.preferred, edits: action @@ -590,8 +590,8 @@ impl Workspace { .into_iter() .map(|edit| edit_to_text_edit(self, file_id.file, &edit)) .collect(), - }); - } + }), + ); } if actions.is_empty() {