♻️ (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
/// 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 {
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();

View file

@ -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<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;
};
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<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;
};
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",

View file

@ -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

View file

@ -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<Cow<'_, str>, NormalizeAsciiError> {
pub fn normalize_ascii_non_blank(input: &str) -> Result<Cow<'_, str>, 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<Cow<'_, str>, 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<Cow<'a, str>> {
match normalize_ascii(input) {
#[track_caller]
pub fn normalize_ascii(input: &str) -> Option<Cow<'_, str>> {
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"
);

View file

@ -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(),