From cf7aba2b533b1f1994ec344207be8dd4a21bfc76 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 4 Jan 2026 19:14:51 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Extiende=20normalizaci=C3=B3n=20de?= =?UTF-8?q?=20cadenas=20ASCII?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/html/classes.rs | 39 ++++++++++++++++----------------------- src/util.rs | 24 ++++++++++++++++++++++++ tests/html_classes.rs | 32 ++++++++++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 25 deletions(-) diff --git a/src/html/classes.rs b/src/html/classes.rs index 806a00bb..3d6e06b7 100644 --- a/src/html/classes.rs +++ b/src/html/classes.rs @@ -1,4 +1,4 @@ -use crate::{builder_fn, trace, util, AutoDefault}; +use crate::{builder_fn, util, AutoDefault}; use std::borrow::Cow; use std::collections::HashSet; @@ -74,16 +74,10 @@ impl Classes { /// lista de clases actual. #[builder_fn] pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef) -> Self { - let normalized = match util::normalize_ascii(classes.as_ref()) { - Ok(c) => c, - Err(util::NormalizeAsciiError::NonAscii) => { - trace::debug!( - classes = %classes.as_ref().escape_default(), - "Classes::with_classes: Ignoring classes due to non-ASCII chars" - ); - return self; - } - _ => Cow::Borrowed(""), + let Some(normalized) = + util::normalize_ascii_or_empty(classes.as_ref(), "Classes::with_classes") + else { + return self; }; match op { ClassesOp::Add => { @@ -116,25 +110,24 @@ impl Classes { self.0.retain(|c| !to_remove.contains(c.as_str())); } ClassesOp::Replace(classes_to_replace) => { - let mut pos = self.0.len(); - let classes_to_replace = match util::normalize_ascii(classes_to_replace.as_ref()) { - Ok(c) => c, - Err(util::NormalizeAsciiError::NonAscii) => { - trace::debug!( - classes = %classes_to_replace.as_ref().escape_default(), - "Classes::with_classes: Invalid replace classes due to non-ASCII chars" - ); - return self; - } - _ => Cow::Borrowed(""), + let Some(classes_to_replace) = util::normalize_ascii_or_empty( + classes_to_replace.as_ref(), + "ClassesOp::Replace", + ) else { + return self; }; + let mut pos = self.0.len(); + let mut replaced = false; for class in classes_to_replace.as_ref().split_ascii_whitespace() { if let Some(replace_pos) = self.0.iter().position(|c| c == class) { self.0.remove(replace_pos); pos = pos.min(replace_pos); + replaced = true; } } - self.add(normalized.as_ref().split_ascii_whitespace(), pos); + if replaced { + self.add(normalized.as_ref().split_ascii_whitespace(), pos); + } } ClassesOp::Toggle => { for class in normalized.as_ref().split_ascii_whitespace() { diff --git a/src/util.rs b/src/util.rs index 45073a61..de275efa 100644 --- a/src/util.rs +++ b/src/util.rs @@ -128,6 +128,30 @@ pub fn normalize_ascii<'a>(input: &'a str) -> Result, NormalizeAsci Ok(Cow::Owned(output)) } +/// Normaliza una cadena ASCII, opcionalmente vacía, con uno o varios tokens separados. +/// +/// - Devuelve `Some(Cow)` si la entrada es válida ASCII (normalizada a minúsculas). +/// - Devuelve `Some(Cow::Borrowed(""))` si la entrada es `""` o queda vacía tras recortar. +/// - Devuelve `None` si la entrada contiene bytes non-ASCII; y emite un `trace::debug!` con el +/// campo `target`. +#[inline] +pub fn normalize_ascii_or_empty<'a>(input: &'a str, target: &'static str) -> Option> { + match normalize_ascii(input) { + Ok(s) => Some(s), + Err(NormalizeAsciiError::NonAscii) => { + trace::debug!( + target = %target, + input = %input.escape_default(), + "Ignoring due to non-ASCII chars" + ); + None + } + Err(NormalizeAsciiError::IsEmpty | NormalizeAsciiError::EmptyAfterTrimming) => { + Some(Cow::Borrowed("")) + } + } +} + /// Resuelve y valida la ruta de un directorio existente, devolviendo una ruta absoluta. /// /// - Si la ruta es relativa, se resuelve respecto al directorio del proyecto según la variable de diff --git a/tests/html_classes.rs b/tests/html_classes.rs index 4b2326dd..91eaaad7 100644 --- a/tests/html_classes.rs +++ b/tests/html_classes.rs @@ -54,6 +54,12 @@ async fn classes_add_same_tokens() { assert_classes(&c, Some("a b")); } +#[pagetop::test] +async fn classes_add_rejects_non_ascii_is_noop() { + let c = Classes::new("a b").with_classes(ClassesOp::Add, "c ñ d"); + assert_classes(&c, Some("a b")); +} + #[pagetop::test] async fn classes_prepend_inserts_at_front_preserving_new_order() { let c = Classes::new("c d").with_classes(ClassesOp::Prepend, "A b"); @@ -139,9 +145,9 @@ async fn classes_replace_removes_targets_and_inserts_new_at_min_position() { } #[pagetop::test] -async fn classes_replace_when_none_found_appends_at_end() { +async fn classes_replace_when_none_found_does_nothing() { let c = Classes::new("a b").with_classes(ClassesOp::Replace("x y".into()), "c d"); - assert_classes(&c, Some("a b c d")); + assert_classes(&c, Some("a b")); } #[pagetop::test] @@ -169,6 +175,12 @@ async fn classes_replace_ignores_target_whitespace_and_repetition() { assert_classes(&c, Some("a x y c")); } +#[pagetop::test] +async fn classes_replace_rejects_non_ascii_targets_is_noop() { + let c = Classes::new("a b c").with_classes(ClassesOp::Replace("b ñ".into()), "x"); + assert_classes(&c, Some("a b c")); +} + // **< Queries (contains) >************************************************************************* #[pagetop::test] @@ -192,6 +204,22 @@ async fn classes_contains_all_and_any() { assert!(!c.contains_any("missing other")); } +#[pagetop::test] +async fn classes_contains_empty_and_whitespace_is_false() { + let c = Classes::new("a b"); + assert!(!c.contains("")); + assert!(!c.contains(" \t")); + assert!(!c.contains_any("")); + assert!(!c.contains_any(" \n ")); +} + +#[pagetop::test] +async fn classes_contains_non_ascii_is_false() { + let c = Classes::new("a b"); + assert!(!c.contains("ñ")); + assert!(!c.contains_any("a ñ")); +} + // **< Properties / regression (combined sequences, ordering) >************************************* #[pagetop::test]