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::lint::LintId; use ty_python_semantic::types::{UNDEFINED_REVEAL, UNRESOLVED_REFERENCE}; /// A `QuickFix` Code Action #[derive(Debug, Clone)] pub struct QuickFix { pub title: String, pub edits: Vec, pub preferred: bool, } pub fn code_actions( db: &dyn Db, file: File, diagnostic_range: TextRange, diagnostic_id: &str, ) -> Vec { let registry = db.lint_registry(); let Ok(lint_id) = registry.get(diagnostic_id) else { return Vec::new(); }; let mut actions = Vec::new(); // Suggest imports/qualifications for unresolved references (often ideal) let is_unresolved_reference = lint_id == LintId::of(&UNRESOLVED_REFERENCE) || lint_id == LintId::of(&UNDEFINED_REVEAL); if is_unresolved_reference && let Some(import_quick_fix) = unresolved_fixes(db, file, diagnostic_range) { actions.extend(import_quick_fix); } // Suggest just suppressing the lint (always a valid option, but never ideal) 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 unresolved_fixes( 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::unresolved_fixes(db, file, &parsed, symbol, node) .into_iter() .map(|import| QuickFix { title: import.label, edits: vec![import.edit], preferred: true, }), ) } #[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_python_trivia::textwrap::dedent; use ruff_text_size::{TextRange, TextSize}; use ty_project::ProjectMetadata; use ty_python_semantic::{ lint::LintMetadata, types::{UNDEFINED_REVEAL, 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:5 | 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] "); } #[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:5 | 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] "); } #[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:5 | 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 ] "); } #[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:5 | 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] "); } #[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:9 | 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:9 | 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 | ) "); } #[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:9 | 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:6 | 2 | b = f""" 3 | {a} | ^ 4 | more text 5 | """ | 2 | b = f""" 3 | {a} 4 | more text - """ 5 + """ # ty:ignore[unresolved-reference] "#); } #[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:5 | 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:5 | 2 | b = a + """ | ^ 3 | more text 4 | """ | 1 | 2 | b = a + """ 3 | more text - """ 4 + """ # ty:ignore[unresolved-reference] "#); } #[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:5 | 2 | b = a \ | ^ 3 | + "test" | 1 | 2 | b = a \ - + "test" 3 + + "test" # ty:ignore[unresolved-reference] "#); } #[test] fn undefined_reveal_type() { let test = CodeActionTest::with_source( r#" reveal_type(1) "#, ); assert_snapshot!(test.code_actions(&UNDEFINED_REVEAL), @r" info[code-action]: import typing.reveal_type --> main.py:2:1 | 2 | reveal_type(1) | ^^^^^^^^^^^ | help: This is a preferred code action 1 + from typing import reveal_type 2 | 3 | reveal_type(1) info[code-action]: Ignore 'undefined-reveal' for this line --> main.py:2:1 | 2 | reveal_type(1) | ^^^^^^^^^^^ | 1 | - reveal_type(1) 2 + reveal_type(1) # ty:ignore[undefined-reveal] "); } #[test] fn unresolved_deprecated() { let test = CodeActionTest::with_source( r#" @deprecated("do not use") def my_func(): ... "#, ); assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r#" info[code-action]: import warnings.deprecated --> main.py:2:2 | 2 | @deprecated("do not use") | ^^^^^^^^^^ 3 | def my_func(): ... | help: This is a preferred code action 1 + from warnings import deprecated 2 | 3 | @deprecated("do not use") 4 | def my_func(): ... info[code-action]: Ignore 'unresolved-reference' for this line --> main.py:2:2 | 2 | @deprecated("do not use") | ^^^^^^^^^^ 3 | def my_func(): ... | 1 | - @deprecated("do not use") 2 + @deprecated("do not use") # ty:ignore[unresolved-reference] 3 | def my_func(): ... "#); } #[test] fn unresolved_deprecated_warnings_imported() { let test = CodeActionTest::with_source( r#" import warnings @deprecated("do not use") def my_func(): ... "#, ); assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r#" info[code-action]: import warnings.deprecated --> main.py:4:2 | 2 | import warnings 3 | 4 | @deprecated("do not use") | ^^^^^^^^^^ 5 | def my_func(): ... | help: This is a preferred code action 1 + from warnings import deprecated 2 | 3 | import warnings 4 | info[code-action]: qualify warnings.deprecated --> main.py:4:2 | 2 | import warnings 3 | 4 | @deprecated("do not use") | ^^^^^^^^^^ 5 | def my_func(): ... | help: This is a preferred code action 1 | 2 | import warnings 3 | - @deprecated("do not use") 4 + @warnings.deprecated("do not use") 5 | def my_func(): ... info[code-action]: Ignore 'unresolved-reference' for this line --> main.py:4:2 | 2 | import warnings 3 | 4 | @deprecated("do not use") | ^^^^^^^^^^ 5 | def my_func(): ... | 1 | 2 | import warnings 3 | - @deprecated("do not use") 4 + @deprecated("do not use") # ty:ignore[unresolved-reference] 5 | def my_func(): ... "#); } // using `importlib.abc.ExecutionLoader` when no imports are in scope #[test] fn unresolved_loader() { let test = CodeActionTest::with_source( r#" ExecutionLoader "#, ); assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r" info[code-action]: import importlib.abc.ExecutionLoader --> main.py:2:1 | 2 | ExecutionLoader | ^^^^^^^^^^^^^^^ | help: This is a preferred code action 1 + from importlib.abc import ExecutionLoader 2 | 3 | ExecutionLoader info[code-action]: Ignore 'unresolved-reference' for this line --> main.py:2:1 | 2 | ExecutionLoader | ^^^^^^^^^^^^^^^ | 1 | - ExecutionLoader 2 + ExecutionLoader # ty:ignore[unresolved-reference] "); } // using `importlib.abc.ExecutionLoader` when `import importlib` is in scope // // TODO: `importlib.abc` is available whenever `importlib` is, so qualifying // `importlib.abc.ExecutionLoader` without adding imports is actually legal here! #[test] fn unresolved_loader_importlib_imported() { let test = CodeActionTest::with_source( r#" import importlib ExecutionLoader "#, ); assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r" info[code-action]: import importlib.abc.ExecutionLoader --> main.py:3:1 | 2 | import importlib 3 | ExecutionLoader | ^^^^^^^^^^^^^^^ | help: This is a preferred code action 1 + from importlib.abc import ExecutionLoader 2 | 3 | import importlib 4 | ExecutionLoader info[code-action]: Ignore 'unresolved-reference' for this line --> main.py:3:1 | 2 | import importlib 3 | ExecutionLoader | ^^^^^^^^^^^^^^^ | 1 | 2 | import importlib - ExecutionLoader 3 + ExecutionLoader # ty:ignore[unresolved-reference] "); } // Using `importlib.abc.ExecutionLoader` when `import importlib.abc` is in scope #[test] fn unresolved_loader_abc_imported() { let test = CodeActionTest::with_source( r#" import importlib.abc ExecutionLoader "#, ); assert_snapshot!(test.code_actions(&UNRESOLVED_REFERENCE), @r" info[code-action]: import importlib.abc.ExecutionLoader --> main.py:3:1 | 2 | import importlib.abc 3 | ExecutionLoader | ^^^^^^^^^^^^^^^ | help: This is a preferred code action 1 + from importlib.abc import ExecutionLoader 2 | 3 | import importlib.abc 4 | ExecutionLoader info[code-action]: qualify importlib.abc.ExecutionLoader --> main.py:3:1 | 2 | import importlib.abc 3 | ExecutionLoader | ^^^^^^^^^^^^^^^ | help: This is a preferred code action 1 | 2 | import importlib.abc - ExecutionLoader 3 + importlib.abc.ExecutionLoader info[code-action]: Ignore 'unresolved-reference' for this line --> main.py:3:1 | 2 | import importlib.abc 3 | ExecutionLoader | ^^^^^^^^^^^^^^^ | 1 | 2 | import importlib.abc - ExecutionLoader 3 + ExecutionLoader # ty:ignore[unresolved-reference] "); } 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 = dedent(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 } } }