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::*;