♻️ (util): Simplifica normalize_ascii*

`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()`.
This commit is contained in:
Manuel Cillero 2026-09-05 21:19:15 +02:00
parent 5939d33964
commit b163b7726e
5 changed files with 70 additions and 57 deletions

View file

@ -96,7 +96,7 @@ impl Autocomplete {
/// El prefijo `section-*` sirve para distinguir entre varios grupos del mismo tipo en una misma /// 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). /// página (p. ej. una dirección de envío y otra de facturación).
pub fn section(name: impl AsRef<str>, field: AutofillField) -> Self { pub fn section(name: impl AsRef<str>, 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(' ') => { Ok(n) if !n.as_ref().contains(' ') => {
Self::custom(util::join!("section-", n.as_ref(), " ", field.as_str())) Self::custom(util::join!("section-", n.as_ref(), " ", field.as_str()))
} }
@ -196,7 +196,7 @@ impl Autocomplete {
let raw = value.as_ref(); let raw = value.as_ref();
// Normaliza la entrada. // 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; return Self::On;
}; };
let autocomplete = normalized.as_ref(); let autocomplete = normalized.as_ref();

View file

@ -539,29 +539,23 @@ impl Props {
} }
} }
PropsOp::AddClasses(classes) => { PropsOp::AddClasses(classes) => {
let Some(normalized) = let Some(normalized) = util::normalize_ascii(classes.as_ref()) else {
util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop")
else {
return self; return self;
}; };
let pos = self.classes.len(); let pos = self.classes.len();
self.insert_classes(normalized.as_ref().split_ascii_whitespace(), pos); self.insert_classes(normalized.as_ref().split_ascii_whitespace(), pos);
} }
PropsOp::PrependClasses(classes) => { PropsOp::PrependClasses(classes) => {
let Some(normalized) = let Some(normalized) = util::normalize_ascii(classes.as_ref()) else {
util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop")
else {
return self; return self;
}; };
self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0); self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0);
} }
PropsOp::ReplaceClasses(old, new) => { PropsOp::ReplaceClasses(old, new) => {
let Some(old) = util::normalize_ascii_or_empty(old.as_ref(), "Props::with_prop") let Some(old) = util::normalize_ascii(old.as_ref()) else {
else {
return self; return self;
}; };
let Some(new) = util::normalize_ascii_or_empty(new.as_ref(), "Props::with_prop") let Some(new) = util::normalize_ascii(new.as_ref()) else {
else {
return self; return self;
}; };
let mut pos = self.classes.len(); let mut pos = self.classes.len();
@ -578,12 +572,10 @@ impl Props {
} }
} }
PropsOp::ReplaceAllClasses(old, new) => { PropsOp::ReplaceAllClasses(old, new) => {
let Some(old) = util::normalize_ascii_or_empty(old.as_ref(), "Props::with_prop") let Some(old) = util::normalize_ascii(old.as_ref()) else {
else {
return self; return self;
}; };
let Some(new) = util::normalize_ascii_or_empty(new.as_ref(), "Props::with_prop") let Some(new) = util::normalize_ascii(new.as_ref()) else {
else {
return self; return self;
}; };
if !self.has_all_classes(old.as_ref()) { if !self.has_all_classes(old.as_ref()) {
@ -599,9 +591,7 @@ impl Props {
self.insert_classes(new.as_ref().split_ascii_whitespace(), pos); self.insert_classes(new.as_ref().split_ascii_whitespace(), pos);
} }
PropsOp::RemoveClasses(classes) => { PropsOp::RemoveClasses(classes) => {
let Some(normalized) = let Some(normalized) = util::normalize_ascii(classes.as_ref()) else {
util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop")
else {
return self; return self;
}; };
self.classes.retain(|c| { self.classes.retain(|c| {
@ -621,9 +611,7 @@ impl Props {
if name.as_ref() == "id" { if name.as_ref() == "id" {
self.apply_id(value.as_ref()); self.apply_id(value.as_ref());
} else if name.as_ref() == "class" { } else if name.as_ref() == "class" {
if let Some(normalized) = if let Some(normalized) = util::normalize_ascii(value.as_ref()) {
util::normalize_ascii_or_empty(value.as_ref(), "Props::with_prop")
{
self.classes.clear(); self.classes.clear();
self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0); 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. /// Devuelve `true` si la clase o **alguna** de las clases indicadas está presente.
pub fn has_classes(&self, classes: impl AsRef<str>) -> bool { pub fn has_classes(&self, classes: impl AsRef<str>) -> 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; return false;
}; };
normalized normalized
@ -776,7 +764,7 @@ impl Props {
/// Devuelve `true` si la clase o **todas** las clases indicadas están presentes. /// Devuelve `true` si la clase o **todas** las clases indicadas están presentes.
pub fn has_all_classes(&self, classes: impl AsRef<str>) -> bool { pub fn has_all_classes(&self, classes: impl AsRef<str>) -> 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; return false;
}; };
normalized normalized
@ -922,15 +910,17 @@ impl Props {
// Añade o sustituye una declaración "propiedad: valor". Si la propiedad ya existe, sustituye // 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 // 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 // propiedad o el valor quedan vacíos tras recortar espacios. No aplica `normalize_ascii`: ver
// normalize_ascii_or_empty: ver la documentación de `PropsOp::AddStyle` sobre por qué los // la documentación de `PropsOp::AddStyle` sobre por qué los valores de estilo no se restringen
// valores de estilo no se restringen a ASCII. // a ASCII.
fn set_style(&mut self, property: &str, value: &str) { fn set_style(&mut self, property: &str, value: &str) {
let property = property.trim().to_ascii_lowercase(); let Some(property) = util::non_blank(property) else {
let value = value.trim();
if property.is_empty() || value.is_empty() {
return; 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) { if let Some(pos) = self.styles.iter().position(|(k, _)| k.as_ref() == property) {
self.styles[pos].1 = value.to_string().into(); self.styles[pos].1 = value.to_string().into();
} else { } else {
@ -943,10 +933,9 @@ impl Props {
// `style`) y aplica cada declaración con `set_style`. Ignora las declaraciones sin ":". // `style`) y aplica cada declaración con `set_style`. Ignora las declaraciones sin ":".
fn parse_styles(&mut self, styles: &str) { fn parse_styles(&mut self, styles: &str) {
for style in Self::split_style_declarations(styles) { for style in Self::split_style_declarations(styles) {
let style = style.trim(); let Some(style) = util::non_blank(style) else {
if style.is_empty() {
continue; continue;
} };
let Some((property, value)) = style.split_once(':') else { let Some((property, value)) = style.split_once(':') else {
trace::debug!( trace::debug!(
target = "Props::with_prop", target = "Props::with_prop",

View file

@ -1,4 +1,5 @@
use crate::global; use crate::global;
use crate::util;
use crate::web::HttpRequest; use crate::web::HttpRequest;
use super::{LangId, LanguageIdentifier, Locale}; use super::{LangId, LanguageIdentifier, Locale};
@ -82,7 +83,7 @@ impl RequestLocale {
// En este primer elemento también puede aparecer `;q=...`, así que se // 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". // 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: // 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 // - Tener en cuenta rangos de idioma (`es`, `en`, etc.) y variantes
// regionales. // regionales.
// - Añadir tests unitarios para distintas combinaciones de cabecera. // - Añadir tests unitarios para distintas combinaciones de cabecera.
if tag.is_empty() { if let Locale::Resolved(langid) = Locale::resolve(tag) {
None
} else if let Locale::Resolved(langid) = Locale::resolve(tag) {
Some(langid) Some(langid)
} else { } else {
None None

View file

@ -5,6 +5,7 @@ use crate::trace;
use std::borrow::Cow; use std::borrow::Cow;
use std::env; use std::env;
use std::io; use std::io;
use std::panic::Location;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
// **< MACROS INTEGRADAS >************************************************************************** // **< MACROS INTEGRADAS >**************************************************************************
@ -39,7 +40,7 @@ pub fn build_runtime() -> tokio::runtime::Runtime {
tokio::runtime::Runtime::new().expect("Failed to build the Tokio 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NormalizeAsciiError { pub enum NormalizeAsciiError {
/// La entrada está vacía (`""`). /// 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) } 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: /// 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 /// Intenta devolver siempre `Cow::Borrowed` para no reservar memoria, y `Cow::Owned` sólo si ha
/// tenido que aplicar cambios para normalizar. /// 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 /// # Ejemplo
/// ///
/// ```rust /// ```rust
/// # use pagetop::util; /// # 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<Cow<'_, str>, NormalizeAsciiError> { pub fn normalize_ascii_non_blank(input: &str) -> Result<Cow<'_, str>, NormalizeAsciiError> {
let bytes = input.as_bytes(); let bytes = input.as_bytes();
if bytes.is_empty() { if bytes.is_empty() {
return Err(NormalizeAsciiError::IsEmpty); return Err(NormalizeAsciiError::IsEmpty);
@ -163,19 +172,35 @@ pub fn normalize_ascii(input: &str) -> Result<Cow<'_, str>, NormalizeAsciiError>
Ok(Cow::Owned(output)) 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)` normalizado (ver [`normalize_ascii_non_blank()`] para las reglas exactas
/// - Devuelve `Some(Cow::Borrowed(""))` si la entrada es `""` o queda vacía tras recortar. /// de recorte, colapso de espacios y minúsculas) si la entrada es ASCII válida.
/// - Devuelve `None` si la entrada contiene bytes no ASCII; y emite un `trace::debug!` con el campo /// - Devuelve `Some(Cow::Borrowed(""))` si la entrada es `""` o queda vacía tras recortar. Una
/// `target`. /// 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] #[inline]
pub fn normalize_ascii_or_empty<'a>(input: &'a str, target: &'static str) -> Option<Cow<'a, str>> { #[track_caller]
match normalize_ascii(input) { pub fn normalize_ascii(input: &str) -> Option<Cow<'_, str>> {
match normalize_ascii_non_blank(input) {
Ok(s) => Some(s), Ok(s) => Some(s),
Err(NormalizeAsciiError::NonAscii) => { Err(NormalizeAsciiError::NonAscii) => {
trace::debug!( trace::debug!(
target = %target, caller = %Location::caller(),
input = %input.escape_default(), input = %input.escape_default(),
"Ignoring due to non-ASCII chars" "Ignoring due to non-ASCII chars"
); );

View file

@ -7,10 +7,10 @@ async fn setup() {
Application::new().await; Application::new().await;
} }
// **< Testing normalize_ascii() >****************************************************************** // **< Testing normalize_ascii_non_blank() >********************************************************
fn assert_err(input: &str, expected: util::NormalizeAsciiError) { fn assert_err(input: &str, expected: util::NormalizeAsciiError) {
let out = util::normalize_ascii(input); let out = util::normalize_ascii_non_blank(input);
assert_eq!( assert_eq!(
out, out,
Err(expected), Err(expected),
@ -22,7 +22,7 @@ fn assert_err(input: &str, expected: util::NormalizeAsciiError) {
} }
fn assert_borrowed(input: &str, expected: &str) { 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_eq!(out.as_ref(), expected, "Input {:?}", input);
assert!( assert!(
matches!(out, Cow::Borrowed(_)), matches!(out, Cow::Borrowed(_)),
@ -33,7 +33,7 @@ fn assert_borrowed(input: &str, expected: &str) {
} }
fn assert_owned(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_eq!(out.as_ref(), expected, "Input {:?}", input);
assert!( assert!(
matches!(out, Cow::Owned(_)), matches!(out, Cow::Owned(_)),
@ -248,8 +248,8 @@ async fn normalize_is_idempotent() {
continue; continue;
} }
let first = util::normalize_ascii(input).unwrap(); let first = util::normalize_ascii_non_blank(input).unwrap();
let second = util::normalize_ascii(first.as_ref()).unwrap(); let second = util::normalize_ascii_non_blank(first.as_ref()).unwrap();
assert_eq!( assert_eq!(
first.as_ref(), first.as_ref(),
second.as_ref(), second.as_ref(),