Skip to content

Commit a8b24fe

Browse files
committed
WIP: feat(format_push_string): add a suggestion
1 parent f095fd7 commit a8b24fe

10 files changed

+1124
-93
lines changed
Lines changed: 175 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
use clippy_utils::diagnostics::span_lint_and_then;
2+
use clippy_utils::macros::{FormatArgsStorage, format_args_inputs_span, root_macro_call_first_node};
23
use clippy_utils::res::MaybeDef;
4+
use clippy_utils::source::{snippet_indent, snippet_with_applicability, snippet_with_context};
5+
use clippy_utils::std_or_core;
6+
use rustc_data_structures::fx::FxHashSet;
7+
use rustc_errors::Applicability;
8+
use rustc_hir::def_id::{DefId, LocalModDefId};
39
use rustc_hir::{AssignOpKind, Expr, ExprKind, LangItem, MatchSource};
4-
use rustc_lint::{LateContext, LateLintPass};
5-
use rustc_session::declare_lint_pass;
6-
use rustc_span::sym;
10+
use rustc_lint::{LateContext, LateLintPass, LintContext};
11+
use rustc_middle::ty::TyCtxt;
12+
use rustc_session::impl_lint_pass;
13+
use rustc_span::{Span, sym};
714

815
declare_clippy_lint! {
916
/// ### What it does
@@ -37,55 +44,188 @@ declare_clippy_lint! {
3744
pedantic,
3845
"`format!(..)` appended to existing `String`"
3946
}
40-
declare_lint_pass!(FormatPushString => [FORMAT_PUSH_STRING]);
47+
impl_lint_pass!(FormatPushString => [FORMAT_PUSH_STRING]);
4148

42-
fn is_string(cx: &LateContext<'_>, e: &Expr<'_>) -> bool {
43-
cx.typeck_results()
44-
.expr_ty(e)
45-
.peel_refs()
46-
.is_lang_item(cx, LangItem::String)
49+
pub(crate) struct FormatPushString {
50+
format_args: FormatArgsStorage,
51+
write_trait: Option<DefId>,
52+
mods_with_import_added: FxHashSet<LocalModDefId>,
53+
}
54+
55+
enum FormatSearchResults {
56+
/// The expression is itself a `format!()` invocation -- we can make a suggestion to replace it
57+
Direct(Span),
58+
/// The expression contains zero or more `format!()`s, e.g.:
59+
/// ```ignore
60+
/// if true {
61+
/// format!("hello")
62+
/// } else {
63+
/// format!("world")
64+
/// }
65+
/// ```
66+
/// or
67+
/// ```ignore
68+
/// match true {
69+
/// true => format!("hello"),
70+
/// false => format!("world"),
71+
/// }
72+
Nested(Vec<Span>),
4773
}
48-
fn is_format(cx: &LateContext<'_>, e: &Expr<'_>) -> bool {
49-
let e = e.peel_blocks().peel_borrows();
5074

51-
match e.kind {
52-
_ if e.span.from_expansion()
53-
&& let Some(macro_def_id) = e.span.ctxt().outer_expn_data().macro_def_id =>
54-
{
55-
cx.tcx.is_diagnostic_item(sym::format_macro, macro_def_id)
56-
},
57-
ExprKind::Match(_, arms, MatchSource::Normal) => arms.iter().any(|arm| is_format(cx, arm.body)),
58-
ExprKind::If(_, then, els) => is_format(cx, then) || els.is_some_and(|e| is_format(cx, e)),
59-
_ => false,
75+
impl FormatPushString {
76+
pub(crate) fn new(tcx: TyCtxt<'_>, format_args: FormatArgsStorage) -> Self {
77+
Self {
78+
format_args,
79+
write_trait: tcx.get_diagnostic_item(sym::FmtWrite),
80+
mods_with_import_added: FxHashSet::default(),
81+
}
82+
}
83+
84+
fn find_formats<'tcx>(&self, cx: &LateContext<'_>, e: &'tcx Expr<'tcx>) -> FormatSearchResults {
85+
let expr_as_format = |e| {
86+
if let Some(macro_call) = root_macro_call_first_node(cx, e)
87+
&& cx.tcx.is_diagnostic_item(sym::format_macro, macro_call.def_id)
88+
&& let Some(format_args) = self.format_args.get(cx, e, macro_call.expn)
89+
{
90+
Some(format_args_inputs_span(format_args))
91+
} else {
92+
None
93+
}
94+
};
95+
96+
let e = e.peel_blocks().peel_borrows();
97+
if let Some(fmt) = expr_as_format(e) {
98+
FormatSearchResults::Direct(fmt)
99+
} else {
100+
fn inner<'tcx>(
101+
e: &'tcx Expr<'tcx>,
102+
expr_as_format: &impl Fn(&'tcx Expr<'tcx>) -> Option<Span>,
103+
out: &mut Vec<Span>,
104+
) {
105+
let e = e.peel_blocks().peel_borrows();
106+
107+
match e.kind {
108+
_ if expr_as_format(e).is_some() => out.push(e.span),
109+
ExprKind::Match(_, arms, MatchSource::Normal) => {
110+
for arm in arms {
111+
inner(arm.body, expr_as_format, out);
112+
}
113+
},
114+
ExprKind::If(_, then, els) => {
115+
inner(then, expr_as_format, out);
116+
if let Some(els) = els {
117+
inner(els, expr_as_format, out);
118+
}
119+
},
120+
_ => {},
121+
}
122+
}
123+
let mut spans = vec![];
124+
inner(e, &expr_as_format, &mut spans);
125+
FormatSearchResults::Nested(spans)
126+
}
60127
}
61128
}
62129

63130
impl<'tcx> LateLintPass<'tcx> for FormatPushString {
64131
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'_>) {
65-
let arg = match expr.kind {
66-
ExprKind::MethodCall(_, _, [arg], _) => {
132+
let (recv, arg) = match expr.kind {
133+
ExprKind::MethodCall(_, recv, [arg], _) => {
67134
if let Some(fn_def_id) = cx.typeck_results().type_dependent_def_id(expr.hir_id)
68135
&& cx.tcx.is_diagnostic_item(sym::string_push_str, fn_def_id)
69136
{
70-
arg
137+
(recv, arg)
71138
} else {
72139
return;
73140
}
74141
},
75-
ExprKind::AssignOp(op, left, arg) if op.node == AssignOpKind::AddAssign && is_string(cx, left) => arg,
142+
ExprKind::AssignOp(op, recv, arg) if op.node == AssignOpKind::AddAssign && is_string(cx, recv) => {
143+
(recv, arg)
144+
},
76145
_ => return,
77146
};
78-
if is_format(cx, arg) {
79-
#[expect(clippy::collapsible_span_lint_calls, reason = "rust-clippy#7797")]
80-
span_lint_and_then(
81-
cx,
82-
FORMAT_PUSH_STRING,
83-
expr.span,
84-
"`format!(..)` appended to existing `String`",
85-
|diag| {
86-
diag.help("consider using `write!` to avoid the extra allocation");
87-
},
88-
);
147+
match self.find_formats(cx, arg) {
148+
FormatSearchResults::Direct(format_args) => {
149+
span_lint_and_then(
150+
cx,
151+
FORMAT_PUSH_STRING,
152+
expr.span,
153+
"`format!(..)` appended to existing `String`",
154+
|diag| {
155+
let mut app = Applicability::MachineApplicable;
156+
let msg = "consider using `write!` to avoid the extra allocation";
157+
158+
let main_sugg = (
159+
expr.span,
160+
format!(
161+
"let _ = write!({recv}, {format_args})",
162+
recv = snippet_with_context(cx.sess(), recv.span, expr.span.ctxt(), "_", &mut app).0,
163+
format_args = snippet_with_applicability(cx.sess(), format_args, "..", &mut app),
164+
),
165+
);
166+
167+
let is_write_in_scope = if let Some(did) = self.write_trait
168+
&& let Some(trait_candidates) = cx.tcx.in_scope_traits(expr.hir_id)
169+
&& trait_candidates
170+
.iter()
171+
.any(|trait_candidate| trait_candidate.def_id == did)
172+
{
173+
true
174+
} else {
175+
false
176+
};
177+
178+
if is_write_in_scope {
179+
diag.span_suggestion_verbose(main_sugg.0, msg, main_sugg.1, app);
180+
} else {
181+
let body_id = cx.tcx.hir_enclosing_body_owner(expr.hir_id);
182+
let scope = cx.tcx.parent_module_from_def_id(body_id);
183+
let (module, _, _) = cx.tcx.hir_get_module(scope);
184+
185+
if self.mods_with_import_added.insert(scope) {
186+
// The trait is not in scope -- we'll need to import it
187+
let import_span = module.spans.inject_use_span;
188+
let import_sugg = (
189+
import_span,
190+
format!(
191+
"use {std}::fmt::Write as _;\n{indent_of_imports}",
192+
std = std_or_core(cx).unwrap(),
193+
indent_of_imports =
194+
snippet_indent(cx.sess(), import_span).as_deref().unwrap_or(""),
195+
),
196+
);
197+
let suggs = vec![main_sugg, import_sugg];
198+
199+
diag.multipart_suggestion_verbose(msg, suggs, app);
200+
} else {
201+
// Another suggestion will have taken care of importing the trait
202+
diag.span_suggestion_verbose(main_sugg.0, msg, main_sugg.1, app);
203+
}
204+
}
205+
},
206+
);
207+
},
208+
FormatSearchResults::Nested(spans) => {
209+
if !spans.is_empty() {
210+
span_lint_and_then(
211+
cx,
212+
FORMAT_PUSH_STRING,
213+
expr.span,
214+
"`format!(..)` appended to existing `String`",
215+
|diag| {
216+
diag.help("consider using `write!` to avoid the extra allocation");
217+
diag.span_labels(spans, "`format!` used here");
218+
},
219+
);
220+
}
221+
},
89222
}
90223
}
91224
}
225+
226+
fn is_string(cx: &LateContext<'_>, e: &Expr<'_>) -> bool {
227+
cx.typeck_results()
228+
.expr_ty(e)
229+
.peel_refs()
230+
.is_lang_item(cx, LangItem::String)
231+
}

clippy_lints/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -736,7 +736,10 @@ pub fn register_lint_passes(store: &mut rustc_lint::LintStore, conf: &'static Co
736736
Box::new(move |_| Box::new(cargo::Cargo::new(conf))),
737737
Box::new(|_| Box::new(empty_with_brackets::EmptyWithBrackets::default())),
738738
Box::new(|_| Box::new(unnecessary_owned_empty_strings::UnnecessaryOwnedEmptyStrings)),
739-
Box::new(|_| Box::new(format_push_string::FormatPushString)),
739+
{
740+
let format_args = format_args_storage.clone();
741+
Box::new(move |tcx| Box::new(format_push_string::FormatPushString::new(tcx, format_args.clone())))
742+
},
740743
Box::new(move |_| Box::new(large_include_file::LargeIncludeFile::new(conf))),
741744
Box::new(|_| Box::new(strings::TrimSplitWhitespace)),
742745
Box::new(|_| Box::new(rc_clone_in_vec_init::RcCloneInVecInit)),

0 commit comments

Comments
 (0)