Skip to content

Commit 407e250

Browse files
committed
WIP: feat(format_push_string): add a suggestion
1 parent 4a14995 commit 407e250

10 files changed

+1119
-90
lines changed
Lines changed: 172 additions & 32 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};
3+
use clippy_utils::source::{snippet_indent, snippet_with_applicability, snippet_with_context};
4+
use clippy_utils::std_or_core;
25
use clippy_utils::ty::is_type_lang_item;
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,52 +44,185 @@ 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-
is_type_lang_item(cx, cx.typeck_results().expr_ty(e).peel_refs(), LangItem::String)
49+
pub(crate) struct FormatPushString {
50+
format_args: FormatArgsStorage,
51+
write_trait: Option<DefId>,
52+
mods_with_import_added: FxHashSet<LocalModDefId>,
4453
}
45-
fn is_format(cx: &LateContext<'_>, e: &Expr<'_>) -> bool {
46-
let e = e.peel_blocks().peel_borrows();
4754

48-
match e.kind {
49-
_ if e.span.from_expansion()
50-
&& let Some(macro_def_id) = e.span.ctxt().outer_expn_data().macro_def_id =>
51-
{
52-
cx.tcx.is_diagnostic_item(sym::format_macro, macro_def_id)
53-
},
54-
ExprKind::Match(_, arms, MatchSource::Normal) => arms.iter().any(|arm| is_format(cx, arm.body)),
55-
ExprKind::If(_, then, els) => is_format(cx, then) || els.is_some_and(|e| is_format(cx, e)),
56-
_ => false,
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>),
73+
}
74+
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+
}
57127
}
58128
}
59129

60130
impl<'tcx> LateLintPass<'tcx> for FormatPushString {
61131
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'_>) {
62-
let arg = match expr.kind {
63-
ExprKind::MethodCall(_, _, [arg], _) => {
132+
let (recv, arg) = match expr.kind {
133+
ExprKind::MethodCall(_, recv, [arg], _) => {
64134
if let Some(fn_def_id) = cx.typeck_results().type_dependent_def_id(expr.hir_id)
65135
&& cx.tcx.is_diagnostic_item(sym::string_push_str, fn_def_id)
66136
{
67-
arg
137+
(recv, arg)
68138
} else {
69139
return;
70140
}
71141
},
72-
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+
},
73145
_ => return,
74146
};
75-
if is_format(cx, arg) {
76-
#[expect(clippy::collapsible_span_lint_calls, reason = "rust-clippy#7797")]
77-
span_lint_and_then(
78-
cx,
79-
FORMAT_PUSH_STRING,
80-
expr.span,
81-
"`format!(..)` appended to existing `String`",
82-
|diag| {
83-
diag.help("consider using `write!` to avoid the extra allocation");
84-
},
85-
);
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+
},
86222
}
87223
}
88224
}
225+
226+
fn is_string(cx: &LateContext<'_>, e: &Expr<'_>) -> bool {
227+
is_type_lang_item(cx, cx.typeck_results().expr_ty(e).peel_refs(), LangItem::String)
228+
}

clippy_lints/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,8 @@ pub fn register_lint_passes(store: &mut rustc_lint::LintStore, conf: &'static Co
700700
store.register_late_pass(|_| Box::new(empty_with_brackets::EmptyWithBrackets::default()));
701701
store.register_late_pass(|_| Box::new(unnecessary_owned_empty_strings::UnnecessaryOwnedEmptyStrings));
702702
store.register_early_pass(|| Box::new(pub_use::PubUse));
703-
store.register_late_pass(|_| Box::new(format_push_string::FormatPushString));
703+
let format_args = format_args_storage.clone();
704+
store.register_late_pass(move |tcx| Box::new(format_push_string::FormatPushString::new(tcx, format_args.clone())));
704705
store.register_late_pass(move |_| Box::new(large_include_file::LargeIncludeFile::new(conf)));
705706
store.register_early_pass(move || Box::new(large_include_file::LargeIncludeFile::new(conf)));
706707
store.register_late_pass(|_| Box::new(strings::TrimSplitWhitespace));

0 commit comments

Comments
 (0)