Implement safety checks for affix removal in slice_to_remove_prefix_or_suffix rule

- Added `FixSafety` enum to determine if affix removal is safe or unsafe.
- Updated diagnostic fixes to use safety checks when applying replacements for prefix and suffix removals.
- Introduced helper functions to classify expression types and assess safety based on semantic analysis.
- Enhanced test cases to cover new functionality and ensure correct behavior of affix removal logic.

This change improves the reliability of the affix removal feature by preventing unsafe modifications that could alter runtime behavior.
This commit is contained in:
shubham humbe 2025-10-10 19:02:05 +05:30
parent 4bd454f9b5
commit 968c9786aa
3 changed files with 163 additions and 11 deletions

View File

@ -205,3 +205,27 @@ def func():
if a.endswith("foo"): if a.endswith("foo"):
a = a[: -len("foo")] a = a[: -len("foo")]
print(a) print(a)
class Seq:
def __init__(self, inner):
self.inner = inner
def startswith(self, prefix):
return tuple(self.inner[: len(prefix)]) == tuple(prefix)
def __getitem__(self, item):
return type(self)(self.inner[item])
def tuple_affix_should_be_suppressed(text: str) -> str:
prefix = ("123",)
if text.startswith(prefix):
text = text[len(prefix):]
return text
def custom_sequence_fix_is_unsafe(seq: Seq) -> Seq:
if seq.startswith("123"):
seq = seq[3:]
return seq

View File

@ -1,6 +1,9 @@
use rustc_hash::FxHashSet;
use ruff_macros::{ViolationMetadata, derive_message_formats}; use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{self as ast, PythonVersion}; use ruff_python_ast::{self as ast, PythonVersion};
use ruff_python_semantic::SemanticModel; use ruff_python_semantic::analyze::typing::find_binding_value;
use ruff_python_semantic::{BindingId, SemanticModel};
use ruff_text_size::Ranged; use ruff_text_size::Ranged;
use crate::Locator; use crate::Locator;
@ -78,6 +81,11 @@ pub(crate) fn slice_to_remove_affix_expr(checker: &Checker, if_expr: &ast::ExprI
let kind = removal_data.affix_query.kind; let kind = removal_data.affix_query.kind;
let text = removal_data.text; let text = removal_data.text;
let Some(fix_safety) = remove_affix_fix_safety(&removal_data, checker.semantic())
else {
return;
};
let mut diagnostic = checker.report_diagnostic( let mut diagnostic = checker.report_diagnostic(
SliceToRemovePrefixOrSuffix { SliceToRemovePrefixOrSuffix {
affix_kind: kind, affix_kind: kind,
@ -88,11 +96,11 @@ pub(crate) fn slice_to_remove_affix_expr(checker: &Checker, if_expr: &ast::ExprI
let replacement = let replacement =
generate_removeaffix_expr(text, &removal_data.affix_query, checker.locator()); generate_removeaffix_expr(text, &removal_data.affix_query, checker.locator());
diagnostic.set_fix(Fix::safe_edit(Edit::replacement( let edit = Edit::replacement(replacement, if_expr.start(), if_expr.end());
replacement, diagnostic.set_fix(match fix_safety {
if_expr.start(), FixSafety::Safe => Fix::safe_edit(edit),
if_expr.end(), FixSafety::Unsafe => Fix::unsafe_edit(edit),
))); });
} }
} }
} }
@ -107,6 +115,11 @@ pub(crate) fn slice_to_remove_affix_stmt(checker: &Checker, if_stmt: &ast::StmtI
let kind = removal_data.affix_query.kind; let kind = removal_data.affix_query.kind;
let text = removal_data.text; let text = removal_data.text;
let Some(fix_safety) = remove_affix_fix_safety(&removal_data, checker.semantic())
else {
return;
};
let mut diagnostic = checker.report_diagnostic( let mut diagnostic = checker.report_diagnostic(
SliceToRemovePrefixOrSuffix { SliceToRemovePrefixOrSuffix {
affix_kind: kind, affix_kind: kind,
@ -121,11 +134,11 @@ pub(crate) fn slice_to_remove_affix_stmt(checker: &Checker, if_stmt: &ast::StmtI
checker.locator(), checker.locator(),
); );
diagnostic.set_fix(Fix::safe_edit(Edit::replacement( let edit = Edit::replacement(replacement, if_stmt.start(), if_stmt.end());
replacement, diagnostic.set_fix(match fix_safety {
if_stmt.start(), FixSafety::Safe => Fix::safe_edit(edit),
if_stmt.end(), FixSafety::Unsafe => Fix::unsafe_edit(edit),
))); });
} }
} }
} }
@ -500,3 +513,84 @@ struct RemoveAffixData<'a> {
/// Contains the prefix or suffix used in `text.startswith(prefix)` or `text.endswith(suffix)` /// Contains the prefix or suffix used in `text.startswith(prefix)` or `text.endswith(suffix)`
affix_query: AffixQuery<'a>, affix_query: AffixQuery<'a>,
} }
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum FixSafety {
Safe,
Unsafe,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum TypeKnowledge {
KnownStr,
KnownBytes,
KnownNonStr,
Unknown,
}
fn remove_affix_fix_safety(data: &RemoveAffixData, semantic: &SemanticModel) -> Option<FixSafety> {
let text_knowledge = classify_expr_type(data.text, semantic);
let affix_knowledge = classify_expr_type(data.affix_query.affix, semantic);
match (text_knowledge, affix_knowledge) {
(TypeKnowledge::KnownNonStr, _) | (_, TypeKnowledge::KnownNonStr) => None,
(TypeKnowledge::KnownStr, TypeKnowledge::KnownStr) => Some(FixSafety::Safe),
(TypeKnowledge::KnownBytes, TypeKnowledge::KnownBytes) => Some(FixSafety::Safe),
(TypeKnowledge::KnownStr, TypeKnowledge::KnownBytes)
| (TypeKnowledge::KnownBytes, TypeKnowledge::KnownStr) => None,
_ => Some(FixSafety::Unsafe),
}
}
fn classify_expr_type(expr: &ast::Expr, semantic: &SemanticModel) -> TypeKnowledge {
let mut seen = FxHashSet::default();
classify_expr_type_inner(expr, semantic, &mut seen)
}
fn classify_expr_type_inner(
expr: &ast::Expr,
semantic: &SemanticModel,
seen: &mut FxHashSet<BindingId>,
) -> TypeKnowledge {
use ast::Expr;
match expr {
_ if expr.is_string_literal_expr() || expr.is_f_string_expr() => TypeKnowledge::KnownStr,
Expr::BytesLiteral(_) => TypeKnowledge::KnownBytes,
Expr::Name(name) => classify_name_expr_type(name, semantic, seen),
Expr::NumberLiteral(_)
| Expr::BooleanLiteral(_)
| Expr::NoneLiteral(_)
| Expr::EllipsisLiteral(_)
| Expr::Tuple(_)
| Expr::List(_)
| Expr::Set(_)
| Expr::Dict(_)
| Expr::ListComp(_)
| Expr::SetComp(_)
| Expr::DictComp(_)
| Expr::Generator(_) => TypeKnowledge::KnownNonStr,
_ => TypeKnowledge::Unknown,
}
}
fn classify_name_expr_type(
name: &ast::ExprName,
semantic: &SemanticModel,
seen: &mut FxHashSet<BindingId>,
) -> TypeKnowledge {
let Some(binding_id) = semantic.only_binding(name) else {
return TypeKnowledge::Unknown;
};
if !seen.insert(binding_id) {
return TypeKnowledge::Unknown;
}
let binding = semantic.binding(binding_id);
if let Some(value) = find_binding_value(binding, semantic) {
return classify_expr_type_inner(value, semantic, seen);
}
TypeKnowledge::Unknown
}

View File

@ -1,5 +1,6 @@
--- ---
source: crates/ruff_linter/src/rules/refurb/mod.rs source: crates/ruff_linter/src/rules/refurb/mod.rs
assertion_line: 62
--- ---
FURB188 [*] Prefer `str.removesuffix()` over conditionally replacing with slice. FURB188 [*] Prefer `str.removesuffix()` over conditionally replacing with slice.
--> FURB188.py:7:5 --> FURB188.py:7:5
@ -21,6 +22,7 @@ help: Use removesuffix instead of assignment conditional upon endswith.
8 | 8 |
9 | return filename 9 | return filename
10 | 10 |
note: This is an unsafe fix and may change runtime behavior
FURB188 [*] Prefer `str.removesuffix()` over conditionally replacing with slice. FURB188 [*] Prefer `str.removesuffix()` over conditionally replacing with slice.
--> FURB188.py:14:5 --> FURB188.py:14:5
@ -42,6 +44,7 @@ help: Use removesuffix instead of assignment conditional upon endswith.
15 | 15 |
16 | return filename 16 | return filename
17 | 17 |
note: This is an unsafe fix and may change runtime behavior
FURB188 [*] Prefer `str.removesuffix()` over conditionally replacing with slice. FURB188 [*] Prefer `str.removesuffix()` over conditionally replacing with slice.
--> FURB188.py:21:12 --> FURB188.py:21:12
@ -59,6 +62,7 @@ help: Use removesuffix instead of ternary expression conditional upon endswith.
22 | 22 |
23 | 23 |
24 | def remove_extension_via_ternary_with_len(filename: str, extension: str) -> str: 24 | def remove_extension_via_ternary_with_len(filename: str, extension: str) -> str:
note: This is an unsafe fix and may change runtime behavior
FURB188 [*] Prefer `str.removesuffix()` over conditionally replacing with slice. FURB188 [*] Prefer `str.removesuffix()` over conditionally replacing with slice.
--> FURB188.py:25:12 --> FURB188.py:25:12
@ -76,6 +80,7 @@ help: Use removesuffix instead of ternary expression conditional upon endswith.
26 | 26 |
27 | 27 |
28 | def remove_prefix(filename: str) -> str: 28 | def remove_prefix(filename: str) -> str:
note: This is an unsafe fix and may change runtime behavior
FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice. FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice.
--> FURB188.py:29:12 --> FURB188.py:29:12
@ -93,6 +98,7 @@ help: Use removeprefix instead of ternary expression conditional upon startswith
30 | 30 |
31 | 31 |
32 | def remove_prefix_via_len(filename: str, prefix: str) -> str: 32 | def remove_prefix_via_len(filename: str, prefix: str) -> str:
note: This is an unsafe fix and may change runtime behavior
FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice. FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice.
--> FURB188.py:33:12 --> FURB188.py:33:12
@ -110,6 +116,7 @@ help: Use removeprefix instead of ternary expression conditional upon startswith
34 | 34 |
35 | 35 |
36 | # these should not 36 | # these should not
note: This is an unsafe fix and may change runtime behavior
FURB188 [*] Prefer `str.removesuffix()` over conditionally replacing with slice. FURB188 [*] Prefer `str.removesuffix()` over conditionally replacing with slice.
--> FURB188.py:146:9 --> FURB188.py:146:9
@ -130,6 +137,7 @@ help: Use removesuffix instead of ternary expression conditional upon endswith.
147 | 147 |
148 | def remove_prefix_comparable_literal_expr() -> None: 148 | def remove_prefix_comparable_literal_expr() -> None:
149 | return ("abc" "def")[3:] if ("abc" "def").startswith("abc") else "abc" "def" 149 | return ("abc" "def")[3:] if ("abc" "def").startswith("abc") else "abc" "def"
note: This is an unsafe fix and may change runtime behavior
FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice. FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice.
--> FURB188.py:149:12 --> FURB188.py:149:12
@ -169,6 +177,7 @@ help: Use removesuffix instead of ternary expression conditional upon endswith.
155 | 155 |
156 | def okay_steps(): 156 | def okay_steps():
157 | text = "!x!y!z" 157 | text = "!x!y!z"
note: This is an unsafe fix and may change runtime behavior
FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice. FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice.
--> FURB188.py:158:5 --> FURB188.py:158:5
@ -213,6 +222,7 @@ help: Use removeprefix instead of assignment conditional upon startswith.
161 | if text.startswith("!"): 161 | if text.startswith("!"):
162 | text = text[1::None] 162 | text = text[1::None]
163 | print(text) 163 | print(text)
note: This is an unsafe fix and may change runtime behavior
FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice. FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice.
--> FURB188.py:162:5 --> FURB188.py:162:5
@ -234,6 +244,7 @@ help: Use removeprefix instead of assignment conditional upon startswith.
163 | print(text) 163 | print(text)
164 | 164 |
165 | 165 |
note: This is an unsafe fix and may change runtime behavior
FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice. FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice.
--> FURB188.py:183:5 --> FURB188.py:183:5
@ -254,6 +265,7 @@ help: Use removeprefix instead of assignment conditional upon startswith.
184 | 184 |
185 | 185 |
186 | def handle_surrogates(): 186 | def handle_surrogates():
note: This is an unsafe fix and may change runtime behavior
FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice. FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice.
--> FURB188.py:190:5 --> FURB188.py:190:5
@ -298,6 +310,7 @@ help: Use removeprefix instead of assignment conditional upon startswith.
194 | 194 |
195 | # should not be linted 195 | # should not be linted
196 | text = "\ud800\udc00heythere" 196 | text = "\ud800\udc00heythere"
note: This is an unsafe fix and may change runtime behavior
FURB188 [*] Prefer `str.removesuffix()` over conditionally replacing with slice. FURB188 [*] Prefer `str.removesuffix()` over conditionally replacing with slice.
--> FURB188.py:205:5 --> FURB188.py:205:5
@ -317,3 +330,24 @@ help: Use removesuffix instead of assignment conditional upon endswith.
- a = a[: -len("foo")] - a = a[: -len("foo")]
205 + a = a.removesuffix("foo") 205 + a = a.removesuffix("foo")
206 | print(a) 206 | print(a)
207 |
208 |
FURB188 [*] Prefer `str.removeprefix()` over conditionally replacing with slice.
--> FURB188.py:229:5
|
228 | def custom_sequence_fix_is_unsafe(seq: Seq) -> Seq:
229 | / if seq.startswith("123"):
230 | | seq = seq[3:]
| |_____________________^
231 | return seq
|
help: Use removeprefix instead of assignment conditional upon startswith.
226 |
227 |
228 | def custom_sequence_fix_is_unsafe(seq: Seq) -> Seq:
- if seq.startswith("123"):
- seq = seq[3:]
229 + seq = seq.removeprefix("123")
230 | return seq
note: This is an unsafe fix and may change runtime behavior