Skip to content

Commit 97c8cfb

Browse files
committed
Preserve suggestion formatting or safely unescape in write_literal
1 parent 7e6a1c4 commit 97c8cfb

File tree

4 files changed

+129
-45
lines changed

4 files changed

+129
-45
lines changed

clippy_lints/src/write.rs

+98-33
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ impl<'tcx> LateLintPass<'tcx> for Write {
290290
}
291291

292292
let Some(args) = format_args.args(cx) else { return };
293-
check_literal(cx, &args, name, format_args.is_raw(cx));
293+
check_literal(cx, &format_args, &args, name);
294294

295295
if !self.in_debug_impl {
296296
for arg in args {
@@ -426,7 +426,7 @@ fn check_empty_string(cx: &LateContext<'_>, format_args: &FormatArgsExpn<'_>, ma
426426
}
427427
}
428428

429-
fn check_literal(cx: &LateContext<'_>, args: &[FormatArgsArg<'_>], name: &str, raw: bool) {
429+
fn check_literal(cx: &LateContext<'_>, format_args: &FormatArgsExpn<'_>, args: &[FormatArgsArg<'_>], name: &str) {
430430
let mut counts = HirIdMap::<usize>::default();
431431
for arg in args {
432432
*counts.entry(arg.value.hir_id).or_default() += 1;
@@ -436,14 +436,24 @@ fn check_literal(cx: &LateContext<'_>, args: &[FormatArgsArg<'_>], name: &str, r
436436
if_chain! {
437437
if counts[&arg.value.hir_id] == 1;
438438
if arg.format_trait == sym::Display;
439+
if let ExprKind::Lit(lit) = &arg.value.kind;
439440
if !arg.has_primitive_formatting();
440441
if !arg.value.span.from_expansion();
441-
if let ExprKind::Lit(lit) = &arg.value.kind;
442+
if let Some(value_string) = snippet_opt(cx, arg.value.span);
443+
if let Some(format_string) = snippet_opt(cx, format_args.format_string_span);
442444
then {
443-
let replacement = match lit.node {
444-
LitKind::Str(s, _) => s.to_string(),
445-
LitKind::Char(c) => c.to_string(),
446-
LitKind::Bool(b) => b.to_string(),
445+
let (replacement, replace_raw) = match lit.node {
446+
LitKind::Str(..) => extract_str_literal(&value_string),
447+
LitKind::Char(ch) => (
448+
match ch {
449+
'"' => "\\\"",
450+
'\'' => "'",
451+
_ => &value_string[1..value_string.len() - 1],
452+
}
453+
.to_string(),
454+
false,
455+
),
456+
LitKind::Bool(b) => (b.to_string(), false),
447457
_ => continue,
448458
};
449459

@@ -453,40 +463,95 @@ fn check_literal(cx: &LateContext<'_>, args: &[FormatArgsArg<'_>], name: &str, r
453463
PRINT_LITERAL
454464
};
455465

466+
let replacement = match (format_string.starts_with('r'), replace_raw) {
467+
(false, false) => Some(replacement),
468+
(false, true) => Some(replacement.replace('"', "\\\"").replace('\\', "\\\\")),
469+
(true, false) => match conservative_unescape(&replacement) {
470+
Ok(unescaped) => Some(unescaped),
471+
Err(UnescapeErr::Lint) => None,
472+
Err(UnescapeErr::Ignore) => continue,
473+
},
474+
(true, true) => {
475+
if replacement.contains(['#', '"']) {
476+
None
477+
} else {
478+
Some(replacement)
479+
}
480+
},
481+
};
482+
456483
span_lint_and_then(cx, lint, arg.value.span, "literal with an empty format string", |diag| {
457-
if raw && replacement.contains(&['"', '#']) {
458-
return;
459-
}
484+
if let Some(replacement) = replacement {
485+
// `format!("{}", "a")`, `format!("{named}", named = "b")
486+
// ~~~~~ ~~~~~~~~~~~~~
487+
let value_span = expand_past_previous_comma(cx, arg.value.span);
460488

461-
let backslash = if raw {
462-
r"\"
463-
} else {
464-
r"\\"
465-
};
466-
let replacement = replacement
467-
.replace('{', "{{")
468-
.replace('}', "}}")
469-
.replace('"', "\\\"")
470-
.replace('\\', backslash);
471-
472-
// `format!("{}", "a")`, `format!("{named}", named = "b")
473-
// ~~~~~ ~~~~~~~~~~~~~
474-
let value_span = expand_past_previous_comma(cx, arg.value.span);
475-
476-
diag.multipart_suggestion(
477-
"try this",
478-
vec![
479-
(arg.span, replacement),
480-
(value_span, String::new()),
481-
],
482-
Applicability::MachineApplicable,
483-
);
489+
let replacement = replacement.replace('{', "{{").replace('}', "}}");
490+
diag.multipart_suggestion(
491+
"try this",
492+
vec![
493+
(arg.span, replacement),
494+
(value_span, String::new()),
495+
],
496+
Applicability::MachineApplicable,
497+
);
498+
}
484499
});
485500
}
486501
}
487502
}
488503
}
489504

505+
/// Removes the raw marker, `#`s and quotes from a str, and returns if the literal is raw
506+
///
507+
/// `r#"a"#` -> (`a`, true)
508+
///
509+
/// `"b"` -> (`b`, false)
510+
fn extract_str_literal(literal: &str) -> (String, bool) {
511+
let (literal, raw) = match literal.strip_prefix('r') {
512+
Some(stripped) => (stripped.trim_matches('#'), true),
513+
None => (literal, false),
514+
};
515+
516+
(literal[1..literal.len() - 1].to_string(), raw)
517+
}
518+
519+
enum UnescapeErr {
520+
/// Should still be linted, can be manually resolved by author, e.g.
521+
///
522+
/// ```ignore
523+
/// print!(r"{}", '"');
524+
/// ```
525+
Lint,
526+
/// Should not be linted, e.g.
527+
///
528+
/// ```ignore
529+
/// print!(r"{}", '\r');
530+
/// ```
531+
Ignore,
532+
}
533+
534+
/// Unescape a normal string into a raw string
535+
fn conservative_unescape(literal: &str) -> Result<String, UnescapeErr> {
536+
let mut unescaped = String::with_capacity(literal.len());
537+
let mut chars = literal.chars();
538+
let mut err = false;
539+
540+
while let Some(ch) = chars.next() {
541+
match ch {
542+
'#' => err = true,
543+
'\\' => match chars.next() {
544+
Some('\\') => unescaped.push('\\'),
545+
Some('"') => err = true,
546+
_ => return Err(UnescapeErr::Ignore),
547+
},
548+
_ => unescaped.push(ch),
549+
}
550+
}
551+
552+
if err { Err(UnescapeErr::Lint) } else { Ok(unescaped) }
553+
}
554+
490555
// Expand from `writeln!(o, "")` to `writeln!(o, "")`
491556
// ^^ ^^^^
492557
fn expand_past_previous_comma(cx: &LateContext<'_>, span: Span) -> Span {

clippy_utils/src/macros.rs

-8
Original file line numberDiff line numberDiff line change
@@ -511,14 +511,6 @@ impl<'tcx> FormatArgsExpn<'tcx> {
511511
.collect()
512512
}
513513

514-
pub fn is_raw(&self, cx: &LateContext<'tcx>) -> bool {
515-
if let Some(format_string) = snippet_opt(cx, self.format_string_span) {
516-
format_string.starts_with('r')
517-
} else {
518-
false
519-
}
520-
}
521-
522514
/// Source callsite span of all inputs
523515
pub fn inputs_span(&self) -> Span {
524516
match *self.value_args {

tests/ui/write_literal_2.rs

+3
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,7 @@ fn main() {
2828
writeln!(v, r"{}", "\\");
2929
writeln!(v, r#"{}"#, "\\");
3030
writeln!(v, "{}", r"\");
31+
writeln!(v, "{}", "\r");
32+
writeln!(v, r#"{}{}"#, '#', '"'); // hard mode
33+
writeln!(v, r"{}", "\r"); // should not lint
3134
}

tests/ui/write_literal_2.stderr

+28-4
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,9 @@ LL | | world!"
7474
|
7575
help: try this
7676
|
77-
LL - "some {}",
78-
LL + "some hello world!"
79-
|
77+
LL ~ "some hello /
78+
LL ~ world!"
79+
|
8080

8181
error: literal with an empty format string
8282
--> $DIR/write_literal_2.rs:25:9
@@ -162,5 +162,29 @@ LL - writeln!(v, "{}", r"/");
162162
LL + writeln!(v, "/");
163163
|
164164

165-
error: aborting due to 14 previous errors
165+
error: literal with an empty format string
166+
--> $DIR/write_literal_2.rs:31:23
167+
|
168+
LL | writeln!(v, "{}", "/r");
169+
| ^^^^
170+
|
171+
help: try this
172+
|
173+
LL - writeln!(v, "{}", "/r");
174+
LL + writeln!(v, "/r");
175+
|
176+
177+
error: literal with an empty format string
178+
--> $DIR/write_literal_2.rs:32:28
179+
|
180+
LL | writeln!(v, r#"{}{}"#, '#', '"'); // hard mode
181+
| ^^^
182+
183+
error: literal with an empty format string
184+
--> $DIR/write_literal_2.rs:32:33
185+
|
186+
LL | writeln!(v, r#"{}{}"#, '#', '"'); // hard mode
187+
| ^^^
188+
189+
error: aborting due to 17 previous errors
166190

0 commit comments

Comments
 (0)