diff --git a/src/html/classes.rs b/src/html/classes.rs index 0123d383..806a00bb 100644 --- a/src/html/classes.rs +++ b/src/html/classes.rs @@ -1,14 +1,17 @@ -use crate::{builder_fn, AutoDefault}; +use crate::{builder_fn, trace, util, AutoDefault}; use std::borrow::Cow; +use std::collections::HashSet; /// Operaciones disponibles sobre la lista de clases en [`Classes`]. /// /// Cada variante opera sobre **una o más clases** proporcionadas como una cadena separada por /// espacios (p. ej. `"btn active"`), que se normalizan internamente a minúsculas en /// [`Classes::with_classes()`]. +#[derive(AutoDefault, Clone, Debug, PartialEq)] pub enum ClassesOp { /// Añade las clases que no existan al final. + #[default] Add, /// Añade las clases que no existan al principio. Prepend, @@ -36,6 +39,7 @@ pub enum ClassesOp { /// /// - Aunque el orden de las clases en el atributo `class` no afecta al resultado en CSS, /// [`ClassesOp`] ofrece operaciones para controlar su orden de aparición por legibilidad. +/// - Solo se acepta una lista de clases con caracteres ASCII. /// - Las clases se almacenan en minúsculas. /// - No se permiten clases duplicadas tras la normalización (por ejemplo, `Btn` y `btn` se /// consideran la misma clase). @@ -59,7 +63,7 @@ pub struct Classes(Vec); impl Classes { /// Crea una nueva lista de clases a partir de la clase o clases proporcionadas en `classes`. pub fn new(classes: impl AsRef) -> Self { - Self::default().with_classes(ClassesOp::Prepend, classes) + Self::default().with_classes(ClassesOp::default(), classes) } // **< Classes BUILDER >************************************************************************ @@ -70,62 +74,80 @@ impl Classes { /// lista de clases actual. #[builder_fn] pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef) -> Self { - let classes = classes.as_ref(); + 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(""), + }; match op { ClassesOp::Add => { - self.add(classes, self.0.len()); + self.add(normalized.as_ref().split_ascii_whitespace(), self.0.len()); } ClassesOp::Prepend => { - self.add(classes, 0); + self.add(normalized.as_ref().split_ascii_whitespace(), 0); } ClassesOp::Remove => { - let mut classes_to_remove = classes.split_ascii_whitespace(); + let mut classes_to_remove = normalized.as_ref().split_ascii_whitespace(); // 0 clases: no se hace nada. let Some(first) = classes_to_remove.next() else { return self; }; - // 1 clase: un único *retain*, cero reservas extra. - let first = first.to_ascii_lowercase(); + // 1 clase: un único *retain*, sin reservas extra. let Some(second) = classes_to_remove.next() else { - self.0.retain(|c| c != &first); + self.0.retain(|c| c != first); return self; }; - // 2+ clases: se construye lista para borrar y un único *retain*. - let mut to_remove = Vec::new(); - to_remove.push(first); - to_remove.push(second.to_ascii_lowercase()); + // 2+ clases: HashSet y un único *retain*. + let mut to_remove: HashSet<&str> = HashSet::new(); + to_remove.insert(first); + to_remove.insert(second); for class in classes_to_remove { - to_remove.push(class.to_ascii_lowercase()); + to_remove.insert(class); } - self.0.retain(|c| !to_remove.iter().any(|r| r == c)); + self.0.retain(|c| !to_remove.contains(c.as_str())); } ClassesOp::Replace(classes_to_replace) => { let mut pos = self.0.len(); - for class in classes_to_replace.split_ascii_whitespace() { - let class = class.to_ascii_lowercase(); - if let Some(replace_pos) = self.0.iter().position(|c| c == &class) { + 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(""), + }; + 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); } } - self.add(classes, pos); + self.add(normalized.as_ref().split_ascii_whitespace(), pos); } ClassesOp::Toggle => { - for class in classes.split_ascii_whitespace() { - let class = class.to_ascii_lowercase(); - if let Some(pos) = self.0.iter().position(|c| c == &class) { + for class in normalized.as_ref().split_ascii_whitespace() { + if let Some(pos) = self.0.iter().position(|c| c == class) { self.0.remove(pos); } else { - self.0.push(class); + self.0.push(class.to_string()); } } } ClassesOp::Set => { self.0.clear(); - self.add(classes, 0); + self.add(normalized.as_ref().split_ascii_whitespace(), 0); } } @@ -133,11 +155,14 @@ impl Classes { } #[inline] - fn add(&mut self, classes: &str, mut pos: usize) { - for class in classes.split_ascii_whitespace() { - let class = class.to_ascii_lowercase(); + fn add<'a, I>(&mut self, classes: I, mut pos: usize) + where + I: IntoIterator, + { + for class in classes { // Inserción segura descartando duplicados. - if !self.0.iter().any(|c| c == &class) { + if !self.0.iter().any(|c| c == class) { + let class = class.to_string(); if pos >= self.0.len() { self.0.push(class); } else { @@ -159,32 +184,25 @@ impl Classes { } } - /// Devuelve `true` si **una única clase** está presente. - /// - /// Si necesitas comprobar varias clases, usa [`contains_all()`](Self::contains_all) o - /// [`contains_any()`](Self::contains_any). - pub fn contains(&self, class: impl AsRef) -> bool { - self.contains_class(class.as_ref()) - } - - /// Devuelve `true` si **todas** las clases indicadas están presentes. - pub fn contains_all(&self, classes: impl AsRef) -> bool { - classes + /// Devuelve `true` si la clase o **todas** las clases indicadas están presentes. + pub fn contains(&self, classes: impl AsRef) -> bool { + let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { + return false; + }; + normalized .as_ref() .split_ascii_whitespace() - .all(|class| self.contains_class(class)) + .all(|class| self.0.iter().any(|c| c == class)) } - /// Devuelve `true` si **alguna** de las clases indicadas está presente. + /// Devuelve `true` si la clase o **alguna** de las clases indicadas está presente. pub fn contains_any(&self, classes: impl AsRef) -> bool { - classes + let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { + return false; + }; + normalized .as_ref() .split_ascii_whitespace() - .any(|class| self.contains_class(class)) - } - - #[inline] - fn contains_class(&self, class: &str) -> bool { - self.0.iter().any(|c| c.eq_ignore_ascii_case(class)) + .any(|class| self.0.iter().any(|c| c == class)) } } diff --git a/src/util.rs b/src/util.rs index ee48e28f..45073a61 100644 --- a/src/util.rs +++ b/src/util.rs @@ -2,6 +2,7 @@ use crate::trace; +use std::borrow::Cow; use std::env; use std::io; use std::path::{Path, PathBuf}; @@ -24,6 +25,109 @@ pub use pagetop_minimal::paste; // **< FUNCIONES ÚTILES >*************************************************************************** +/// Errores posibles al normalizar una cadena ASCII con [`normalize_ascii()`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NormalizeAsciiError { + /// La entrada está vacía (`""`). + IsEmpty, + /// La entrada quedó vacía tras recortar separadores ASCII al inicio/fin. + EmptyAfterTrimming, + /// La entrada contiene al menos un byte no ASCII (>= 0x80). + NonAscii, +} + +/// Normaliza una cadena ASCII con uno o varios tokens separados. +/// +/// Los *separadores* son caracteres `is_ascii_whitespace()` como `' '`, `'\t'`, `'\n'` o `'\r'`. +/// +/// Reglas: +/// +/// - Devuelve `Err(NormalizeAsciiError::IsEmpty)` si la entrada es `""`. +/// - Devuelve `Err(NormalizeAsciiError::NonAscii)` si contiene algún byte no ASCII (`>= 0x80`). +/// - Devuelve `Err(NormalizeAsciiError::EmptyAfterTrimming)` si después de recortar separadores al +/// inicio/fin, la entrada queda vacía. +/// - Sustituye cualquier secuencia de separadores por un único espacio `' '`. +/// - El resultado queda siempre en minúsculas. +/// +/// Intenta devolver siempre `Cow::Borrowed` para no reservar memoria, y `Cow::Owned` sólo si ha +/// tenido que aplicar cambios para normalizar. +/// +/// # Ejemplo +/// +/// ```rust +/// # use pagetop::util; +/// assert_eq!(util::normalize_ascii(" Foo\tBAR CLi\r\n").unwrap().as_ref(), "foo bar cli"); +/// ``` +pub fn normalize_ascii<'a>(input: &'a str) -> Result, NormalizeAsciiError> { + let bytes = input.as_bytes(); + if bytes.is_empty() { + return Err(NormalizeAsciiError::IsEmpty); + } + + let mut start = 0usize; + let mut end = 0usize; + + let mut needs_alloc = false; + let mut needs_alloc_ws = false; + let mut has_content = false; + let mut prev_sep = false; + + for (pos, &b) in bytes.iter().enumerate() { + if !b.is_ascii() { + return Err(NormalizeAsciiError::NonAscii); + } + if b.is_ascii_whitespace() { + if has_content { + if b != b' ' || prev_sep { + needs_alloc_ws = true; + } + prev_sep = true; + } + } else { + if needs_alloc_ws { + needs_alloc = true; + needs_alloc_ws = false; + } + if b.is_ascii_uppercase() { + needs_alloc = true; + } + prev_sep = false; + if !has_content { + start = pos; + has_content = true; + } + end = pos + 1; + } + } + + if !has_content { + return Err(NormalizeAsciiError::EmptyAfterTrimming); + } + + let slice = &input[start..end]; + + if !needs_alloc { + return Ok(Cow::Borrowed(slice)); + } + + let mut output = String::with_capacity(slice.len()); + let mut prev_sep = true; + + for &b in slice.as_bytes() { + if b.is_ascii_whitespace() { + if !prev_sep { + output.push(' '); + prev_sep = true; + } + } else { + output.push(b.to_ascii_lowercase() as char); + prev_sep = false; + } + } + + Ok(Cow::Owned(output)) +} + /// 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 00de96c5..4b2326dd 100644 --- a/tests/html_classes.rs +++ b/tests/html_classes.rs @@ -2,7 +2,13 @@ use pagetop::prelude::*; fn assert_classes(c: &Classes, expected: Option<&str>) { let got = c.get(); - assert_eq!(got.as_deref(), expected, "Expected {:?}, got {:?}", expected, got); + assert_eq!( + got.as_deref(), + expected, + "Expected {:?}, got {:?}", + expected, + got + ); } // **< Construction & invariants (new/get) >******************************************************** @@ -177,9 +183,9 @@ async fn classes_contains_single() { async fn classes_contains_all_and_any() { let c = Classes::new("btn btn-primary active"); - assert!(c.contains_all("btn active")); - assert!(c.contains_all("BTN BTN-PRIMARY")); - assert!(!c.contains_all("btn missing")); + assert!(c.contains("btn active")); + assert!(c.contains("BTN BTN-PRIMARY")); + assert!(!c.contains("btn missing")); assert!(c.contains_any("missing active")); assert!(c.contains_any("BTN-PRIMARY missing")); diff --git a/tests/util.rs b/tests/util.rs index 70699a74..d7d8dd65 100644 --- a/tests/util.rs +++ b/tests/util.rs @@ -1,8 +1,264 @@ use pagetop::prelude::*; -use std::{env, fs, io}; +use std::{borrow::Cow, env, fs, io}; use tempfile::TempDir; +// **< Testing normalize_ascii() >****************************************************************** + +fn assert_err(input: &str, expected: util::NormalizeAsciiError) { + let out = util::normalize_ascii(input); + assert_eq!( + out, + Err(expected), + "Input {:?} expected Err({:?}), got {:?}", + input, + expected, + out + ); +} + +fn assert_borrowed(input: &str, expected: &str) { + let out = util::normalize_ascii(input).expect("Expected Ok(..)"); + assert_eq!(out.as_ref(), expected, "Input {:?}", input); + assert!( + matches!(out, Cow::Borrowed(_)), + "Expected Cow::Borrowed, got {:?} for input {:?}", + out, + input + ); +} + +fn assert_owned(input: &str, expected: &str) { + let out = util::normalize_ascii(input).expect("Expected Ok(..)"); + assert_eq!(out.as_ref(), expected, "Input {:?}", input); + assert!( + matches!(out, Cow::Owned(_)), + "Expected Cow::Owned, got {:?} for input {:?}", + out, + input + ); +} + +#[pagetop::test] +async fn normalize_errors() { + // Caso especial: cadena vacía. + assert_err("", util::NormalizeAsciiError::IsEmpty); + + // Sólo separadores ASCII: tras el recorte no queda nada. + for input in [" ", " ", "\t", "\n", "\r", "\t \n\r "] { + assert_err(input, util::NormalizeAsciiError::EmptyAfterTrimming); + } + + // Cualquier byte no-ASCII debe fallar, aunque el resto pueda normalizarse. + for input in [ + "©", + "á", + "😀", + "a©b", + "a b © c", + " Foo©BAR ", + "\tAáB\n", + "x y😀", + ] { + assert_err(input, util::NormalizeAsciiError::NonAscii); + } +} + +#[pagetop::test] +async fn normalize_borrowed_trim_and_already_normalized() { + // Sólo recorte (incluyendo separadores al final). + for (input, expected) in [ + (" a", "a"), + ("a ", "a"), + (" \t\n a \r ", "a"), + ("foo\t", "foo"), + ("foo \t\r\n", "foo"), + (" \n\tfoo\r", "foo"), + ("\tfoo", "foo"), + ("\nfoo", "foo"), + ("\rfoo", "foo"), + ("\t\r\nfoo\r\n\t", "foo"), + ("foo\t\t\t", "foo"), + ("foo\r\n", "foo"), + ("foo \r\n\t", "foo"), + ] { + assert_borrowed(input, expected); + } + + // Ya normalizado (minúsculas y un único espacio entre tokens). + for input in [ + "a", + "a b", + "a b c", + "foo bar baz", + "btn", + "btn btn-primary", + "col-12 col-md-6", + "username webauthn", + "off", + "on", + "foo-bar", + "foo_bar", + "a.b,c", + "path/to/resource", + "foo+bar=baz", + "a-._:/+=", + "a\x1Bb", // Byte de control ASCII: se conserva tal cual. + ] { + assert_borrowed(input, input); + } + + // Separador "raro" al final de la cadena: se recorta y se devuelve porción. + for (input, expected) in [ + ("foo bar\t", "foo bar"), + ("foo bar\r\n", "foo bar"), + ("foo bar \r\n", "foo bar"), + ] { + assert_borrowed(input, expected); + } +} + +#[pagetop::test] +async fn normalize_owned_due_to_uppercase() { + // Sólo por mayúsculas (y otros ASCII que se preservan). + for (input, expected) in [ + ("A", "a"), + ("Foo", "foo"), + ("FOO BAR", "foo bar"), + ("a B c", "a b c"), + ("ABC", "abc"), + ("abcDEF", "abcdef"), + ("Abc-Def_Ghi", "abc-def_ghi"), + ("X.Y,Z", "x.y,z"), + ("Foo-Bar", "foo-bar"), + ("FOO_BAR", "foo_bar"), + ("A.B,C", "a.b,c"), + ("HTTP/2", "http/2"), + ("ETag:W/\"XYZ\"", "etag:w/\"xyz\""), + ("Foo+Bar=Baz", "foo+bar=baz"), + ("A-._:/+=", "a-._:/+="), + ("A\x1BB", "a\x1bb"), // Sólo letras en minúsculas; el byte de control se conserva. + ] { + assert_owned(input, expected); + } +} + +#[pagetop::test] +async fn normalize_owned_due_to_internal_whitespace() { + // Espacios consecutivos (deben colapsar a un único espacio). + for (input, expected) in [("a b", "a b"), ("a b", "a b")] { + assert_owned(input, expected); + } + + // Separadores ASCII distintos de ' ' entre tokens (tab, newline, CR, CRLF). + for (input, expected) in [ + ("a\tb", "a b"), + ("a\nb", "a b"), + ("a\rb", "a b"), + ("a\r\nb", "a b"), + ("foo\tbar", "foo bar"), + ("foo\nbar", "foo bar"), + ("foo\rbar", "foo bar"), + ("foo\r\nbar", "foo bar"), + ] { + assert_owned(input, expected); + } + + // Mezclas de separadores. + for (input, expected) in [ + ("a \t \n b", "a b"), + ("a\t \n b", "a b"), + ("foo \tbar", "foo bar"), + ("foo\t bar", "foo bar"), + ("foo\t\tbar", "foo bar"), + ("foo \n\t\r bar", "foo bar"), + ] { + assert_owned(input, expected); + } + + // El resultado nunca debe tener espacios al inicio/fin (tras normalizar). + for (input, expected) in [ + (" a b ", "a b"), + (" a\tb ", "a b"), + (" a\nb ", "a b"), + ] { + assert_owned(input, expected); + } +} + +#[pagetop::test] +async fn normalize_owned_due_to_mixed_causes() { + // Combinaciones de mayúsculas y separador no normalizado. + for (input, expected) in [ + (" Foo BAR\tbaz ", "foo bar baz"), + ("\nFOO\rbar\tBAZ\n", "foo bar baz"), + ("FOO\tbar", "foo bar"), + ("foo\tBAR", "foo bar"), + ("FOO\tBAR", "foo bar"), + ("Foo BAR\tBaz", "foo bar baz"), + ("x\t y ", "x y"), + ("x y\t", "x y"), + ] { + assert_owned(input, expected); + } +} + +#[pagetop::test] +async fn normalize_borrowed_vs_owned_edge_cases() { + // Un sólo token con separador al final. + for (input, expected) in [("x ", "x"), ("x\t", "x"), ("x\n", "x"), ("x\r\n", "x")] { + assert_borrowed(input, expected); + } + + // Dos tokens con separador no normalizado. + for input in ["x y", "x\t\ty", "x \t y", "x\r\ny"] { + assert_owned(input, "x y"); + } + + // Dos tokens con separación limpia. + for (input, expected) in [("x y ", "x y"), ("x y\t", "x y"), ("x y\r\n", "x y")] { + assert_borrowed(input, expected); + } +} + +#[pagetop::test] +async fn normalize_is_idempotent() { + // La normalización debe ser idempotente: normalizar el resultado no cambia nada. + let cases = [ + "a", + "a b c", + "foo-bar", + "foo_bar", + "a.b,c", + " Foo BAR\tbaz ", + "foo\tbar", + "x y\t", + "\tfoo\r\n", + "a\x1Bb", + "HTTP/2", + ]; + + for &input in &cases { + // Todos son ASCII, pero se deja este control por si se amplía la lista en el futuro. + if !input.is_ascii() { + continue; + } + + let first = util::normalize_ascii(input).unwrap(); + let second = util::normalize_ascii(first.as_ref()).unwrap(); + assert_eq!( + first.as_ref(), + second.as_ref(), + "Idempotency failed for input {:?}: first={:?} second={:?}", + input, + first.as_ref(), + second.as_ref() + ); + } +} + +// **< Testing resolve_absolute_dir() >************************************************************* + #[cfg(unix)] mod unix { use super::*;