ruff/crates/ty_ide/src/code_action.rs

778 lines
22 KiB
Rust

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<Edit>,
pub preferred: bool,
}
pub fn code_actions(
db: &dyn Db,
file: File,
diagnostic_range: TextRange,
diagnostic_id: &str,
) -> Vec<QuickFix> {
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<impl Iterator<Item = QuickFix>> {
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 = <START>a<END> / 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 = <START>a<END> / 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 = <START>a<END> / 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 = <START>a<END> / 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 = <START>a<END> / 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 = <START>a<END> / 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 = <START>a<END> / 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 = (
<START>a # ty:ignore[division-by-zero]
/
0<END>
)
"#,
);
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 = (
<START>a
/
0<END> # 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 = (
<START>a # ty:ignore[division-by-zero]
/
0<END> # 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"""
{<START>a<END>}
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"""
{
<START>a<END>
}
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 = <START>a<END> + """
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 = <START>a<END> \
+ "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#"
<START>reveal_type<END>(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#"
@<START>deprecated<END>("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
@<START>deprecated<END>("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#"
<START>ExecutionLoader<END>
"#,
);
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
<START>ExecutionLoader<END>
"#,
);
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
<START>ExecutionLoader<END>
"#,
);
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("<START>")
.expect("source text should contain a `<START>` marker");
cleansed.replace_range(start..start + "<START>".len(), "");
let end = cleansed
.find("<END>")
.expect("source text should contain a `<END>` marker");
cleansed.replace_range(end..end + "<END>".len(), "");
assert!(start <= end, "<START> marker should be before <END> 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
}
}
}