From b163b7726e792d1dfbdcf77fc711ba0ebc61c21a Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 5 Sep 2026 21:19:15 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(util):=20Simplifica=20nor?= =?UTF-8?q?malize=5Fascii*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalize_ascii_or_empty()` pasa a llamarse `normalize_ascii()` (la variante indulgente, la más usada); la antigua `normalize_ascii()` pasa a `normalize_ascii_non_blank()`. --- src/base/component/form/props.rs | 4 +-- src/html/props.rs | 53 +++++++++++++------------------- src/locale/request.rs | 7 ++--- src/util.rs | 51 ++++++++++++++++++++++-------- tests/util.rs | 12 ++++---- 5 files changed, 70 insertions(+), 57 deletions(-) diff --git a/src/base/component/form/props.rs b/src/base/component/form/props.rs index 31067f44..5211b33d 100644 --- a/src/base/component/form/props.rs +++ b/src/base/component/form/props.rs @@ -96,7 +96,7 @@ impl Autocomplete { /// El prefijo `section-*` sirve para distinguir entre varios grupos del mismo tipo en una misma /// página (p. ej. una dirección de envío y otra de facturación). pub fn section(name: impl AsRef, field: AutofillField) -> Self { - match util::normalize_ascii(name.as_ref()) { + match util::normalize_ascii_non_blank(name.as_ref()) { Ok(n) if !n.as_ref().contains(' ') => { Self::custom(util::join!("section-", n.as_ref(), " ", field.as_str())) } @@ -196,7 +196,7 @@ impl Autocomplete { let raw = value.as_ref(); // Normaliza la entrada. - let Some(normalized) = util::normalize_ascii_or_empty(raw, "Autocomplete::custom") else { + let Some(normalized) = util::normalize_ascii(raw) else { return Self::On; }; let autocomplete = normalized.as_ref(); diff --git a/src/html/props.rs b/src/html/props.rs index 55a24d35..c001d953 100644 --- a/src/html/props.rs +++ b/src/html/props.rs @@ -539,29 +539,23 @@ impl Props { } } PropsOp::AddClasses(classes) => { - let Some(normalized) = - util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop") - else { + let Some(normalized) = util::normalize_ascii(classes.as_ref()) else { return self; }; let pos = self.classes.len(); self.insert_classes(normalized.as_ref().split_ascii_whitespace(), pos); } PropsOp::PrependClasses(classes) => { - let Some(normalized) = - util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop") - else { + let Some(normalized) = util::normalize_ascii(classes.as_ref()) else { return self; }; self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0); } PropsOp::ReplaceClasses(old, new) => { - let Some(old) = util::normalize_ascii_or_empty(old.as_ref(), "Props::with_prop") - else { + let Some(old) = util::normalize_ascii(old.as_ref()) else { return self; }; - let Some(new) = util::normalize_ascii_or_empty(new.as_ref(), "Props::with_prop") - else { + let Some(new) = util::normalize_ascii(new.as_ref()) else { return self; }; let mut pos = self.classes.len(); @@ -578,12 +572,10 @@ impl Props { } } PropsOp::ReplaceAllClasses(old, new) => { - let Some(old) = util::normalize_ascii_or_empty(old.as_ref(), "Props::with_prop") - else { + let Some(old) = util::normalize_ascii(old.as_ref()) else { return self; }; - let Some(new) = util::normalize_ascii_or_empty(new.as_ref(), "Props::with_prop") - else { + let Some(new) = util::normalize_ascii(new.as_ref()) else { return self; }; if !self.has_all_classes(old.as_ref()) { @@ -599,9 +591,7 @@ impl Props { self.insert_classes(new.as_ref().split_ascii_whitespace(), pos); } PropsOp::RemoveClasses(classes) => { - let Some(normalized) = - util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop") - else { + let Some(normalized) = util::normalize_ascii(classes.as_ref()) else { return self; }; self.classes.retain(|c| { @@ -621,9 +611,7 @@ impl Props { if name.as_ref() == "id" { self.apply_id(value.as_ref()); } else if name.as_ref() == "class" { - if let Some(normalized) = - util::normalize_ascii_or_empty(value.as_ref(), "Props::with_prop") - { + if let Some(normalized) = util::normalize_ascii(value.as_ref()) { self.classes.clear(); self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0); } @@ -765,7 +753,7 @@ impl Props { /// Devuelve `true` si la clase o **alguna** de las clases indicadas está presente. pub fn has_classes(&self, classes: impl AsRef) -> bool { - let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { + let Ok(normalized) = util::normalize_ascii_non_blank(classes.as_ref()) else { return false; }; normalized @@ -776,7 +764,7 @@ impl Props { /// Devuelve `true` si la clase o **todas** las clases indicadas están presentes. pub fn has_all_classes(&self, classes: impl AsRef) -> bool { - let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { + let Ok(normalized) = util::normalize_ascii_non_blank(classes.as_ref()) else { return false; }; normalized @@ -922,15 +910,17 @@ impl Props { // Añade o sustituye una declaración "propiedad: valor". Si la propiedad ya existe, sustituye // su valor conservando la posición; si no, la añade al final. Ignora la declaración si la - // propiedad o el valor quedan vacíos tras recortar espacios. No aplica - // normalize_ascii_or_empty: ver la documentación de `PropsOp::AddStyle` sobre por qué los - // valores de estilo no se restringen a ASCII. + // propiedad o el valor quedan vacíos tras recortar espacios. No aplica `normalize_ascii`: ver + // la documentación de `PropsOp::AddStyle` sobre por qué los valores de estilo no se restringen + // a ASCII. fn set_style(&mut self, property: &str, value: &str) { - let property = property.trim().to_ascii_lowercase(); - let value = value.trim(); - if property.is_empty() || value.is_empty() { + let Some(property) = util::non_blank(property) else { return; - } + }; + let property = property.to_ascii_lowercase(); + let Some(value) = util::non_blank(value) else { + return; + }; if let Some(pos) = self.styles.iter().position(|(k, _)| k.as_ref() == property) { self.styles[pos].1 = value.to_string().into(); } else { @@ -943,10 +933,9 @@ impl Props { // `style`) y aplica cada declaración con `set_style`. Ignora las declaraciones sin ":". fn parse_styles(&mut self, styles: &str) { for style in Self::split_style_declarations(styles) { - let style = style.trim(); - if style.is_empty() { + let Some(style) = util::non_blank(style) else { continue; - } + }; let Some((property, value)) = style.split_once(':') else { trace::debug!( target = "Props::with_prop", diff --git a/src/locale/request.rs b/src/locale/request.rs index 10aaa5d0..19dc91b3 100644 --- a/src/locale/request.rs +++ b/src/locale/request.rs @@ -1,4 +1,5 @@ use crate::global; +use crate::util; use crate::web::HttpRequest; use super::{LangId, LanguageIdentifier, Locale}; @@ -82,7 +83,7 @@ impl RequestLocale { // En este primer elemento también puede aparecer `;q=...`, así que se // extrae únicamente la etiqueta de idioma: "es-ES;q=0.9" -> "es-ES". - let tag = first.split(';').next()?.trim(); + let tag = util::non_blank(first.split(';').next()?)?; // TODO: Mejorar el soporte de `Accept-Language` en el futuro: // @@ -92,9 +93,7 @@ impl RequestLocale { // - Tener en cuenta rangos de idioma (`es`, `en`, etc.) y variantes // regionales. // - Añadir tests unitarios para distintas combinaciones de cabecera. - if tag.is_empty() { - None - } else if let Locale::Resolved(langid) = Locale::resolve(tag) { + if let Locale::Resolved(langid) = Locale::resolve(tag) { Some(langid) } else { None diff --git a/src/util.rs b/src/util.rs index 0c0ebf22..ed3f9c1e 100644 --- a/src/util.rs +++ b/src/util.rs @@ -5,6 +5,7 @@ use crate::trace; use std::borrow::Cow; use std::env; use std::io; +use std::panic::Location; use std::path::{Path, PathBuf}; // **< MACROS INTEGRADAS >************************************************************************** @@ -39,7 +40,7 @@ pub fn build_runtime() -> tokio::runtime::Runtime { tokio::runtime::Runtime::new().expect("Failed to build the Tokio runtime") } -/// Errores posibles al normalizar una cadena ASCII con [`normalize_ascii()`]. +/// Errores al normalizar una cadena ASCII con [`normalize_ascii_non_blank()`]. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum NormalizeAsciiError { /// La entrada está vacía (`""`). @@ -69,9 +70,10 @@ pub fn non_blank(s: &str) -> Option<&str> { if s.is_empty() { None } else { Some(s) } } -/// Normaliza una cadena ASCII con uno o varios tokens separados. +/// Normaliza una cadena ASCII con uno o varios tokens, exigiendo contenido no vacío. /// -/// Los *separadores* son caracteres `is_ascii_whitespace()` como `' '`, `'\t'`, `'\n'` o `'\r'`. +/// Los *separadores* de los tokens son caracteres `is_ascii_whitespace()` como `' '`, `'\t'`, +/// `'\n'` o `'\r'`. /// /// Reglas: /// @@ -85,13 +87,20 @@ pub fn non_blank(s: &str) -> Option<&str> { /// Intenta devolver siempre `Cow::Borrowed` para no reservar memoria, y `Cow::Owned` sólo si ha /// tenido que aplicar cambios para normalizar. /// +/// Normalmente se usará [`normalize_ascii()`], que trata una cadena en blanco como resultado +/// válido, en vez de como un error. Esta variante es para detectar también cuándo un resultado es +/// una cadena vacía para rechazarla de inmediato. +/// /// # Ejemplo /// /// ```rust /// # use pagetop::util; -/// assert_eq!(util::normalize_ascii(" Foo\tBAR CLi\r\n").unwrap().as_ref(), "foo bar cli"); +/// assert_eq!( +/// util::normalize_ascii_non_blank(" Foo\tBAR CLi\r\n").unwrap().as_ref(), +/// "foo bar cli" +/// ); /// ``` -pub fn normalize_ascii(input: &str) -> Result, NormalizeAsciiError> { +pub fn normalize_ascii_non_blank(input: &str) -> Result, NormalizeAsciiError> { let bytes = input.as_bytes(); if bytes.is_empty() { return Err(NormalizeAsciiError::IsEmpty); @@ -163,19 +172,35 @@ pub fn normalize_ascii(input: &str) -> Result, NormalizeAsciiError> Ok(Cow::Owned(output)) } -/// Normaliza una cadena ASCII, opcionalmente vacía, con uno o varios tokens separados. +/// Normaliza una cadena ASCII con uno o varios tokens, aceptando cadenas vacías. /// -/// - 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 no ASCII; y emite un `trace::debug!` con el campo -/// `target`. +/// - Devuelve `Some(Cow)` normalizado (ver [`normalize_ascii_non_blank()`] para las reglas exactas +/// de recorte, colapso de espacios y minúsculas) si la entrada es ASCII válida. +/// - Devuelve `Some(Cow::Borrowed(""))` si la entrada es `""` o queda vacía tras recortar. Una +/// entrada en blanco no es un error, es un resultado trivial. +/// - Devuelve `None` **sólo** si la entrada contiene bytes no ASCII; y emite un `trace::debug!` con +/// la ubicación exacta del llamador (vía `#[track_caller]`) para poder localizar el origen del +/// valor rechazado. +/// +/// # Ejemplo +/// +/// ```rust +/// # use pagetop::util; +/// assert_eq!( +/// util::normalize_ascii(" Foo\tBAR CLi\r\n").unwrap().as_ref(), +/// "foo bar cli" +/// ); +/// assert_eq!(util::normalize_ascii(" ").unwrap().as_ref(), ""); +/// assert_eq!(util::normalize_ascii("ñoño"), None); +/// ``` #[inline] -pub fn normalize_ascii_or_empty<'a>(input: &'a str, target: &'static str) -> Option> { - match normalize_ascii(input) { +#[track_caller] +pub fn normalize_ascii(input: &str) -> Option> { + match normalize_ascii_non_blank(input) { Ok(s) => Some(s), Err(NormalizeAsciiError::NonAscii) => { trace::debug!( - target = %target, + caller = %Location::caller(), input = %input.escape_default(), "Ignoring due to non-ASCII chars" ); diff --git a/tests/util.rs b/tests/util.rs index 0730a2c4..4ed69e01 100644 --- a/tests/util.rs +++ b/tests/util.rs @@ -7,10 +7,10 @@ async fn setup() { Application::new().await; } -// **< Testing normalize_ascii() >****************************************************************** +// **< Testing normalize_ascii_non_blank() >******************************************************** fn assert_err(input: &str, expected: util::NormalizeAsciiError) { - let out = util::normalize_ascii(input); + let out = util::normalize_ascii_non_blank(input); assert_eq!( out, Err(expected), @@ -22,7 +22,7 @@ fn assert_err(input: &str, expected: util::NormalizeAsciiError) { } fn assert_borrowed(input: &str, expected: &str) { - let out = util::normalize_ascii(input).expect("Expected Ok(..)"); + let out = util::normalize_ascii_non_blank(input).expect("Expected Ok(..)"); assert_eq!(out.as_ref(), expected, "Input {:?}", input); assert!( matches!(out, Cow::Borrowed(_)), @@ -33,7 +33,7 @@ fn assert_borrowed(input: &str, expected: &str) { } fn assert_owned(input: &str, expected: &str) { - let out = util::normalize_ascii(input).expect("Expected Ok(..)"); + let out = util::normalize_ascii_non_blank(input).expect("Expected Ok(..)"); assert_eq!(out.as_ref(), expected, "Input {:?}", input); assert!( matches!(out, Cow::Owned(_)), @@ -248,8 +248,8 @@ async fn normalize_is_idempotent() { continue; } - let first = util::normalize_ascii(input).unwrap(); - let second = util::normalize_ascii(first.as_ref()).unwrap(); + let first = util::normalize_ascii_non_blank(input).unwrap(); + let second = util::normalize_ascii_non_blank(first.as_ref()).unwrap(); assert_eq!( first.as_ref(), second.as_ref(),