diff --git a/src/html/classes.rs b/src/html/classes.rs index 3d6e06b7..0123d383 100644 --- a/src/html/classes.rs +++ b/src/html/classes.rs @@ -1,17 +1,14 @@ -use crate::{builder_fn, util, AutoDefault}; +use crate::{builder_fn, 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, @@ -39,7 +36,6 @@ 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). @@ -63,7 +59,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::default(), classes) + Self::default().with_classes(ClassesOp::Prepend, classes) } // **< Classes BUILDER >************************************************************************ @@ -74,73 +70,62 @@ impl Classes { /// lista de clases actual. #[builder_fn] pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef) -> Self { - let Some(normalized) = - util::normalize_ascii_or_empty(classes.as_ref(), "Classes::with_classes") - else { - return self; - }; + let classes = classes.as_ref(); match op { ClassesOp::Add => { - self.add(normalized.as_ref().split_ascii_whitespace(), self.0.len()); + self.add(classes, self.0.len()); } ClassesOp::Prepend => { - self.add(normalized.as_ref().split_ascii_whitespace(), 0); + self.add(classes, 0); } ClassesOp::Remove => { - let mut classes_to_remove = normalized.as_ref().split_ascii_whitespace(); + let mut classes_to_remove = classes.split_ascii_whitespace(); // 0 clases: no se hace nada. let Some(first) = classes_to_remove.next() else { return self; }; - // 1 clase: un único *retain*, sin reservas extra. + // 1 clase: un único *retain*, cero reservas extra. + let first = first.to_ascii_lowercase(); let Some(second) = classes_to_remove.next() else { - self.0.retain(|c| c != first); + self.0.retain(|c| c != &first); return self; }; - // 2+ clases: HashSet y un único *retain*. - let mut to_remove: HashSet<&str> = HashSet::new(); - to_remove.insert(first); - to_remove.insert(second); + // 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()); for class in classes_to_remove { - to_remove.insert(class); + to_remove.push(class.to_ascii_lowercase()); } - self.0.retain(|c| !to_remove.contains(c.as_str())); + self.0.retain(|c| !to_remove.iter().any(|r| r == c)); } ClassesOp::Replace(classes_to_replace) => { - 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) { + 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) { self.0.remove(replace_pos); pos = pos.min(replace_pos); - replaced = true; } } - if replaced { - self.add(normalized.as_ref().split_ascii_whitespace(), pos); - } + self.add(classes, pos); } ClassesOp::Toggle => { - for class in normalized.as_ref().split_ascii_whitespace() { - if let Some(pos) = self.0.iter().position(|c| c == class) { + for class in classes.split_ascii_whitespace() { + let class = class.to_ascii_lowercase(); + if let Some(pos) = self.0.iter().position(|c| c == &class) { self.0.remove(pos); } else { - self.0.push(class.to_string()); + self.0.push(class); } } } ClassesOp::Set => { self.0.clear(); - self.add(normalized.as_ref().split_ascii_whitespace(), 0); + self.add(classes, 0); } } @@ -148,14 +133,11 @@ impl Classes { } #[inline] - fn add<'a, I>(&mut self, classes: I, mut pos: usize) - where - I: IntoIterator, - { - for class in classes { + fn add(&mut self, classes: &str, mut pos: usize) { + for class in classes.split_ascii_whitespace() { + let class = class.to_ascii_lowercase(); // Inserción segura descartando duplicados. - if !self.0.iter().any(|c| c == class) { - let class = class.to_string(); + if !self.0.iter().any(|c| c == &class) { if pos >= self.0.len() { self.0.push(class); } else { @@ -177,25 +159,32 @@ impl 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.0.iter().any(|c| c == class)) + /// 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 la clase o **alguna** de las clases indicadas está presente. - pub fn contains_any(&self, classes: impl AsRef) -> bool { - let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { - return false; - }; - normalized + /// Devuelve `true` si **todas** las clases indicadas están presentes. + pub fn contains_all(&self, classes: impl AsRef) -> bool { + classes .as_ref() .split_ascii_whitespace() - .any(|class| self.0.iter().any(|c| c == class)) + .all(|class| self.contains_class(class)) + } + + /// Devuelve `true` si **alguna** de las clases indicadas está presente. + pub fn contains_any(&self, classes: impl AsRef) -> bool { + classes + .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)) } } diff --git a/src/util.rs b/src/util.rs index de275efa..ee48e28f 100644 --- a/src/util.rs +++ b/src/util.rs @@ -2,7 +2,6 @@ use crate::trace; -use std::borrow::Cow; use std::env; use std::io; use std::path::{Path, PathBuf}; @@ -25,133 +24,6 @@ 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)) -} - -/// 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 91eaaad7..00de96c5 100644 --- a/tests/html_classes.rs +++ b/tests/html_classes.rs @@ -2,13 +2,7 @@ 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) >******************************************************** @@ -54,12 +48,6 @@ 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"); @@ -145,9 +133,9 @@ async fn classes_replace_removes_targets_and_inserts_new_at_min_position() { } #[pagetop::test] -async fn classes_replace_when_none_found_does_nothing() { +async fn classes_replace_when_none_found_appends_at_end() { let c = Classes::new("a b").with_classes(ClassesOp::Replace("x y".into()), "c d"); - assert_classes(&c, Some("a b")); + assert_classes(&c, Some("a b c d")); } #[pagetop::test] @@ -175,12 +163,6 @@ 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] @@ -195,31 +177,15 @@ async fn classes_contains_single() { async fn classes_contains_all_and_any() { let c = Classes::new("btn btn-primary active"); - assert!(c.contains("btn active")); - assert!(c.contains("BTN BTN-PRIMARY")); - assert!(!c.contains("btn missing")); + assert!(c.contains_all("btn active")); + assert!(c.contains_all("BTN BTN-PRIMARY")); + assert!(!c.contains_all("btn missing")); assert!(c.contains_any("missing active")); assert!(c.contains_any("BTN-PRIMARY missing")); 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] diff --git a/tests/util.rs b/tests/util.rs index d7d8dd65..70699a74 100644 --- a/tests/util.rs +++ b/tests/util.rs @@ -1,264 +1,8 @@ use pagetop::prelude::*; -use std::{borrow::Cow, env, fs, io}; +use std::{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::*;