use regex::Regex;
use std::sync::LazyLock;
use ruff_python_ast::{
self as ast, BytesLiteralFlags, Expr, FStringFlags, FStringPart, InterpolatedStringElement,
InterpolatedStringLiteralElement, Stmt, StringFlags,
};
use ruff_python_ast::{AtomicNodeIndex, visitor::transformer::Transformer};
use ruff_python_ast::{StringLiteralFlags, visitor::transformer};
use ruff_text_size::{Ranged, TextRange};
pub(crate) struct Normalizer;
impl Normalizer {
#[allow(dead_code)]
pub(crate) fn visit_module(&self, module: &mut ast::Mod) {
match module {
ast::Mod::Module(module) => {
self.visit_body(&mut module.body);
}
ast::Mod::Expression(expression) => {
self.visit_expr(&mut expression.body);
}
}
}
}
impl Transformer for Normalizer {
fn visit_stmt(&self, stmt: &mut Stmt) {
if let Stmt::Delete(delete) = stmt {
if let [Expr::Tuple(tuple)] = delete.targets.as_slice() {
delete.targets = tuple.elts.clone();
}
}
transformer::walk_stmt(self, stmt);
}
fn visit_expr(&self, expr: &mut Expr) {
match expr {
Expr::StringLiteral(string) if string.value.is_implicit_concatenated() => {
let can_join = string.value.iter().all(|literal| {
!literal.flags.is_triple_quoted() && !literal.flags.prefix().is_raw()
});
if can_join {
string.value = ast::StringLiteralValue::single(ast::StringLiteral {
value: Box::from(string.value.to_str()),
range: string.range,
flags: StringLiteralFlags::empty(),
node_index: AtomicNodeIndex::NONE,
});
}
}
Expr::BytesLiteral(bytes) if bytes.value.is_implicit_concatenated() => {
let can_join = bytes.value.iter().all(|literal| {
!literal.flags.is_triple_quoted() && !literal.flags.prefix().is_raw()
});
if can_join {
bytes.value = ast::BytesLiteralValue::single(ast::BytesLiteral {
value: bytes.value.bytes().collect(),
range: bytes.range,
flags: BytesLiteralFlags::empty(),
node_index: AtomicNodeIndex::NONE,
});
}
}
Expr::FString(fstring) if fstring.value.is_implicit_concatenated() => {
let can_join = fstring.value.iter().all(|part| match part {
FStringPart::Literal(literal) => {
!literal.flags.is_triple_quoted() && !literal.flags.prefix().is_raw()
}
FStringPart::FString(string) => {
!string.flags.is_triple_quoted() && !string.flags.prefix().is_raw()
}
});
if can_join {
#[derive(Default)]
struct Collector {
elements: Vec<InterpolatedStringElement>,
}
impl Collector {
fn push_literal(&mut self, literal: &str, range: TextRange) {
if let Some(InterpolatedStringElement::Literal(existing_literal)) =
self.elements.last_mut()
{
let value = std::mem::take(&mut existing_literal.value);
let mut value = value.into_string();
value.push_str(literal);
existing_literal.value = value.into_boxed_str();
existing_literal.range =
TextRange::new(existing_literal.start(), range.end());
} else {
self.elements.push(InterpolatedStringElement::Literal(
InterpolatedStringLiteralElement {
range,
value: literal.into(),
node_index: AtomicNodeIndex::NONE,
},
));
}
}
fn push_expression(&mut self, expression: ast::InterpolatedElement) {
self.elements
.push(InterpolatedStringElement::Interpolation(expression));
}
}
let mut collector = Collector::default();
for part in &fstring.value {
match part {
ast::FStringPart::Literal(string_literal) => {
collector.push_literal(&string_literal.value, string_literal.range);
}
ast::FStringPart::FString(fstring) => {
for element in &fstring.elements {
match element {
ast::InterpolatedStringElement::Literal(literal) => {
collector.push_literal(&literal.value, literal.range);
}
ast::InterpolatedStringElement::Interpolation(
expression,
) => {
collector.push_expression(expression.clone());
}
}
}
}
}
}
fstring.value = ast::FStringValue::single(ast::FString {
elements: collector.elements.into(),
range: fstring.range,
flags: FStringFlags::empty(),
node_index: AtomicNodeIndex::NONE,
});
}
}
_ => {}
}
transformer::walk_expr(self, expr);
}
fn visit_interpolated_string_element(
&self,
interpolated_string_element: &mut InterpolatedStringElement,
) {
let InterpolatedStringElement::Interpolation(interpolation) = interpolated_string_element
else {
return;
};
let Some(debug) = &mut interpolation.debug_text else {
return;
};
let leading = debug.leading().replace("\r\n", "\n").replace('\r', "\n");
let expression = debug.expression().to_string();
let trailing = debug.trailing().replace("\r\n", "\n").replace('\r', "\n");
*debug = ast::DebugText::new(&leading, &expression, &trailing);
}
fn visit_string_literal(&self, string_literal: &mut ast::StringLiteral) {
static STRIP_DOC_TESTS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?mx)
(
# strip doctest PS1 prompt lines
^\s*>>>\s.*(\n|$)
|
# strip doctest PS2 prompt lines
# Also handles the case of an empty ... line.
^\s*\.\.\.((\n|$)|\s.*(\n|$))
)+
",
)
.unwrap()
});
static STRIP_RST_BLOCKS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"::(?s:.*)").unwrap()
});
static STRIP_MARKDOWN_BLOCKS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(```|~~~)\p{any}*(```|~~~|$)").unwrap()
});
string_literal.value = STRIP_DOC_TESTS
.replace_all(
&string_literal.value,
"<DOCTEST-CODE-SNIPPET: Removed by normalizer>\n",
)
.into_owned()
.into_boxed_str();
string_literal.value = STRIP_RST_BLOCKS
.replace_all(
&string_literal.value,
"<RSTBLOCK-CODE-SNIPPET: Removed by normalizer>\n",
)
.into_owned()
.into_boxed_str();
string_literal.value = STRIP_MARKDOWN_BLOCKS
.replace_all(
&string_literal.value,
"<MARKDOWN-CODE-SNIPPET: Removed by normalizer>\n",
)
.into_owned()
.into_boxed_str();
string_literal.value = string_literal
.value
.lines()
.map(str::trim)
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_owned()
.into_boxed_str();
}
}