Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
727 changes: 0 additions & 727 deletions clippy_lints/src/write.rs

This file was deleted.

38 changes: 38 additions & 0 deletions clippy_lints/src/write/empty_string.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
use clippy_utils::diagnostics::span_lint_and_then;
use clippy_utils::macros::MacroCall;
use clippy_utils::source::expand_past_previous_comma;
use clippy_utils::sym;
use rustc_ast::{FormatArgs, FormatArgsPiece};
use rustc_errors::Applicability;
use rustc_lint::LateContext;

use super::{PRINTLN_EMPTY_STRING, WRITELN_EMPTY_STRING};

pub(super) fn check(cx: &LateContext<'_>, format_args: &FormatArgs, macro_call: &MacroCall, name: &str) {
if let [FormatArgsPiece::Literal(sym::LF)] = &format_args.template[..] {
let mut span = format_args.span;

let lint = if name == "writeln" {
span = expand_past_previous_comma(cx, span);

WRITELN_EMPTY_STRING
} else {
PRINTLN_EMPTY_STRING
};

span_lint_and_then(
cx,
lint,
macro_call.span,
format!("empty string literal in `{name}!`"),
|diag| {
diag.span_suggestion(
span,
"remove the empty string",
String::new(),
Applicability::MachineApplicable,
);
},
);
}
}
285 changes: 285 additions & 0 deletions clippy_lints/src/write/literal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
use clippy_utils::diagnostics::span_lint_and_then;
use clippy_utils::macros::format_arg_removal_span;
use clippy_utils::source::SpanRangeExt;
use clippy_utils::sym;
use rustc_ast::token::LitKind;
use rustc_ast::{
FormatArgPosition, FormatArgPositionKind, FormatArgs, FormatArgsPiece, FormatCount, FormatOptions,
FormatPlaceholder, FormatTrait,
};
use rustc_errors::Applicability;
use rustc_lint::LateContext;
use rustc_span::Span;

use super::{PRINT_LITERAL, WRITE_LITERAL};

pub(super) fn check(cx: &LateContext<'_>, format_args: &FormatArgs, name: &str) {
let arg_index = |argument: &FormatArgPosition| argument.index.unwrap_or_else(|pos| pos);

let lint_name = if name.starts_with("write") {
WRITE_LITERAL
} else {
PRINT_LITERAL
};

let mut counts = vec![0u32; format_args.arguments.all_args().len()];
for piece in &format_args.template {
if let FormatArgsPiece::Placeholder(placeholder) = piece {
counts[arg_index(&placeholder.argument)] += 1;
}
}

let mut suggestion: Vec<(Span, String)> = vec![];
// holds index of replaced positional arguments; used to decrement the index of the remaining
// positional arguments.
let mut replaced_position: Vec<usize> = vec![];
let mut sug_span: Option<Span> = None;

for piece in &format_args.template {
if let FormatArgsPiece::Placeholder(FormatPlaceholder {
argument,
span: Some(placeholder_span),
format_trait: FormatTrait::Display,
format_options,
}) = piece
&& *format_options == FormatOptions::default()
&& let index = arg_index(argument)
&& counts[index] == 1
&& let Some(arg) = format_args.arguments.by_index(index)
&& let rustc_ast::ExprKind::Lit(lit) = &arg.expr.kind
&& !arg.expr.span.from_expansion()
&& let Some(value_string) = arg.expr.span.get_source_text(cx)
{
let (replacement, replace_raw) = match lit.kind {
LitKind::Str | LitKind::StrRaw(_) => match extract_str_literal(&value_string) {
Some(extracted) => extracted,
None => return,
},
LitKind::Char => (
match lit.symbol {
sym::DOUBLE_QUOTE => "\\\"",
sym::BACKSLASH_SINGLE_QUOTE => "'",
_ => match value_string.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
Some(stripped) => stripped,
None => return,
},
}
.to_string(),
false,
),
LitKind::Bool => (lit.symbol.to_string(), false),
_ => continue,
};

let Some(format_string_snippet) = format_args.span.get_source_text(cx) else {
continue;
};
let format_string_is_raw = format_string_snippet.starts_with('r');

let replacement = match (format_string_is_raw, replace_raw) {
(false, false) => Some(replacement),
(false, true) => Some(replacement.replace('\\', "\\\\").replace('"', "\\\"")),
(true, false) => match conservative_unescape(&replacement) {
Ok(unescaped) => Some(unescaped),
Err(UnescapeErr::Lint) => None,
Err(UnescapeErr::Ignore) => continue,
},
(true, true) => {
if replacement.contains(['#', '"']) {
None
} else {
Some(replacement)
}
},
};

sug_span = Some(sug_span.unwrap_or(arg.expr.span).to(arg.expr.span));

if let Some((_, index)) = format_arg_piece_span(piece) {
replaced_position.push(index);
}

if let Some(replacement) = replacement
// `format!("{}", "a")`, `format!("{named}", named = "b")
// ~~~~~ ~~~~~~~~~~~~~
&& let Some(removal_span) = format_arg_removal_span(format_args, index)
{
let replacement = escape_braces(&replacement, !format_string_is_raw && !replace_raw);
suggestion.push((*placeholder_span, replacement));
suggestion.push((removal_span, String::new()));
}
}
}

// Decrement the index of the remaining by the number of replaced positional arguments
if !suggestion.is_empty() {
for piece in &format_args.template {
relocalize_format_args_indexes(piece, &mut suggestion, &replaced_position);
}
}

if let Some(span) = sug_span {
span_lint_and_then(cx, lint_name, span, "literal with an empty format string", |diag| {
if !suggestion.is_empty() {
diag.multipart_suggestion("try", suggestion, Applicability::MachineApplicable);
}
});
}
}

/// Extract Span and its index from the given `piece`
fn format_arg_piece_span(piece: &FormatArgsPiece) -> Option<(Span, usize)> {
match piece {
FormatArgsPiece::Placeholder(FormatPlaceholder {
argument: FormatArgPosition { index: Ok(index), .. },
span: Some(span),
..
}) => Some((*span, *index)),
_ => None,
}
}

/// Relocalizes the indexes of positional arguments in the format string
fn relocalize_format_args_indexes(
piece: &FormatArgsPiece,
suggestion: &mut Vec<(Span, String)>,
replaced_position: &[usize],
) {
if let FormatArgsPiece::Placeholder(FormatPlaceholder {
argument:
FormatArgPosition {
index: Ok(index),
// Only consider positional arguments
kind: FormatArgPositionKind::Number,
span: Some(span),
},
format_options,
..
}) = piece
{
if suggestion.iter().any(|(s, _)| s.overlaps(*span)) {
// If the span is already in the suggestion, we don't need to process it again
return;
}

// lambda to get the decremented index based on the replaced positions
let decremented_index = |index: usize| -> usize {
let decrement = replaced_position.iter().filter(|&&i| i < index).count();
index - decrement
};

suggestion.push((*span, decremented_index(*index).to_string()));

// If there are format options, we need to handle them as well
if *format_options != FormatOptions::default() {
// lambda to process width and precision format counts and add them to the suggestion
let mut process_format_count = |count: &Option<FormatCount>, formatter: &dyn Fn(usize) -> String| {
if let Some(FormatCount::Argument(FormatArgPosition {
index: Ok(format_arg_index),
kind: FormatArgPositionKind::Number,
span: Some(format_arg_span),
})) = count
{
suggestion.push((*format_arg_span, formatter(decremented_index(*format_arg_index))));
}
};

process_format_count(&format_options.width, &|index: usize| format!("{index}$"));
process_format_count(&format_options.precision, &|index: usize| format!(".{index}$"));
}
}
}

/// Removes the raw marker, `#`s and quotes from a str, and returns if the literal is raw
///
/// `r#"a"#` -> (`a`, true)
///
/// `"b"` -> (`b`, false)
fn extract_str_literal(literal: &str) -> Option<(String, bool)> {
let (literal, raw) = match literal.strip_prefix('r') {
Some(stripped) => (stripped.trim_matches('#'), true),
None => (literal, false),
};

Some((literal.strip_prefix('"')?.strip_suffix('"')?.to_string(), raw))
}

enum UnescapeErr {
/// Should still be linted, can be manually resolved by author, e.g.
///
/// ```ignore
/// print!(r"{}", '"');
/// ```
Lint,
/// Should not be linted, e.g.
///
/// ```ignore
/// print!(r"{}", '\r');
/// ```
Ignore,
}

/// Unescape a normal string into a raw string
fn conservative_unescape(literal: &str) -> Result<String, UnescapeErr> {
let mut unescaped = String::with_capacity(literal.len());
let mut chars = literal.chars();
let mut err = false;

while let Some(ch) = chars.next() {
match ch {
'#' => err = true,
'\\' => match chars.next() {
Some('\\') => unescaped.push('\\'),
Some('"') => err = true,
_ => return Err(UnescapeErr::Ignore),
},
_ => unescaped.push(ch),
}
}

if err { Err(UnescapeErr::Lint) } else { Ok(unescaped) }
}

/// Replaces `{` with `{{` and `}` with `}}`. If `preserve_unicode_escapes` is `true` the braces
/// in `\u{xxxx}` are left unmodified
#[expect(clippy::match_same_arms)]
fn escape_braces(literal: &str, preserve_unicode_escapes: bool) -> String {
#[derive(Clone, Copy)]
enum State {
Normal,
Backslash,
UnicodeEscape,
}

let mut escaped = String::with_capacity(literal.len());
let mut state = State::Normal;

for ch in literal.chars() {
state = match (ch, state) {
// Escape braces outside of unicode escapes by doubling them up
('{' | '}', State::Normal) => {
escaped.push(ch);
State::Normal
},
// If `preserve_unicode_escapes` isn't enabled stay in `State::Normal`, otherwise:
//
// \u{aaaa} \\ \x01
// ^ ^ ^
('\\', State::Normal) if preserve_unicode_escapes => State::Backslash,
// \u{aaaa}
// ^
('u', State::Backslash) => State::UnicodeEscape,
// \xAA \\
// ^ ^
(_, State::Backslash) => State::Normal,
// \u{aaaa}
// ^
('}', State::UnicodeEscape) => State::Normal,
_ => state,
};

escaped.push(ch);
}

escaped
}
Loading