Compare commits
2 commits
41d5091348
...
cf7aba2b53
| Author | SHA1 | Date | |
|---|---|---|---|
| cf7aba2b53 | |||
| e9d326cd99 |
4 changed files with 484 additions and 55 deletions
|
|
@ -1,14 +1,17 @@
|
||||||
use crate::{builder_fn, AutoDefault};
|
use crate::{builder_fn, util, AutoDefault};
|
||||||
|
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
/// Operaciones disponibles sobre la lista de clases en [`Classes`].
|
/// Operaciones disponibles sobre la lista de clases en [`Classes`].
|
||||||
///
|
///
|
||||||
/// Cada variante opera sobre **una o más clases** proporcionadas como una cadena separada por
|
/// 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
|
/// espacios (p. ej. `"btn active"`), que se normalizan internamente a minúsculas en
|
||||||
/// [`Classes::with_classes()`].
|
/// [`Classes::with_classes()`].
|
||||||
|
#[derive(AutoDefault, Clone, Debug, PartialEq)]
|
||||||
pub enum ClassesOp {
|
pub enum ClassesOp {
|
||||||
/// Añade las clases que no existan al final.
|
/// Añade las clases que no existan al final.
|
||||||
|
#[default]
|
||||||
Add,
|
Add,
|
||||||
/// Añade las clases que no existan al principio.
|
/// Añade las clases que no existan al principio.
|
||||||
Prepend,
|
Prepend,
|
||||||
|
|
@ -36,6 +39,7 @@ pub enum ClassesOp {
|
||||||
///
|
///
|
||||||
/// - Aunque el orden de las clases en el atributo `class` no afecta al resultado en CSS,
|
/// - 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.
|
/// [`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.
|
/// - Las clases se almacenan en minúsculas.
|
||||||
/// - No se permiten clases duplicadas tras la normalización (por ejemplo, `Btn` y `btn` se
|
/// - No se permiten clases duplicadas tras la normalización (por ejemplo, `Btn` y `btn` se
|
||||||
/// consideran la misma clase).
|
/// consideran la misma clase).
|
||||||
|
|
@ -59,7 +63,7 @@ pub struct Classes(Vec<String>);
|
||||||
impl Classes {
|
impl Classes {
|
||||||
/// Crea una nueva lista de clases a partir de la clase o clases proporcionadas en `classes`.
|
/// Crea una nueva lista de clases a partir de la clase o clases proporcionadas en `classes`.
|
||||||
pub fn new(classes: impl AsRef<str>) -> Self {
|
pub fn new(classes: impl AsRef<str>) -> Self {
|
||||||
Self::default().with_classes(ClassesOp::Prepend, classes)
|
Self::default().with_classes(ClassesOp::default(), classes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// **< Classes BUILDER >************************************************************************
|
// **< Classes BUILDER >************************************************************************
|
||||||
|
|
@ -70,62 +74,73 @@ impl Classes {
|
||||||
/// lista de clases actual.
|
/// lista de clases actual.
|
||||||
#[builder_fn]
|
#[builder_fn]
|
||||||
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
|
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
|
||||||
let classes = classes.as_ref();
|
let Some(normalized) =
|
||||||
|
util::normalize_ascii_or_empty(classes.as_ref(), "Classes::with_classes")
|
||||||
|
else {
|
||||||
|
return self;
|
||||||
|
};
|
||||||
match op {
|
match op {
|
||||||
ClassesOp::Add => {
|
ClassesOp::Add => {
|
||||||
self.add(classes, self.0.len());
|
self.add(normalized.as_ref().split_ascii_whitespace(), self.0.len());
|
||||||
}
|
}
|
||||||
ClassesOp::Prepend => {
|
ClassesOp::Prepend => {
|
||||||
self.add(classes, 0);
|
self.add(normalized.as_ref().split_ascii_whitespace(), 0);
|
||||||
}
|
}
|
||||||
ClassesOp::Remove => {
|
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.
|
// 0 clases: no se hace nada.
|
||||||
let Some(first) = classes_to_remove.next() else {
|
let Some(first) = classes_to_remove.next() else {
|
||||||
return self;
|
return self;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1 clase: un único *retain*, cero reservas extra.
|
// 1 clase: un único *retain*, sin reservas extra.
|
||||||
let first = first.to_ascii_lowercase();
|
|
||||||
let Some(second) = classes_to_remove.next() else {
|
let Some(second) = classes_to_remove.next() else {
|
||||||
self.0.retain(|c| c != &first);
|
self.0.retain(|c| c != first);
|
||||||
return self;
|
return self;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2+ clases: se construye lista para borrar y un único *retain*.
|
// 2+ clases: HashSet y un único *retain*.
|
||||||
let mut to_remove = Vec::new();
|
let mut to_remove: HashSet<&str> = HashSet::new();
|
||||||
to_remove.push(first);
|
to_remove.insert(first);
|
||||||
to_remove.push(second.to_ascii_lowercase());
|
to_remove.insert(second);
|
||||||
for class in classes_to_remove {
|
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) => {
|
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 pos = self.0.len();
|
||||||
for class in classes_to_replace.split_ascii_whitespace() {
|
let mut replaced = false;
|
||||||
let class = class.to_ascii_lowercase();
|
for class in classes_to_replace.as_ref().split_ascii_whitespace() {
|
||||||
if let Some(replace_pos) = self.0.iter().position(|c| c == &class) {
|
if let Some(replace_pos) = self.0.iter().position(|c| c == class) {
|
||||||
self.0.remove(replace_pos);
|
self.0.remove(replace_pos);
|
||||||
pos = pos.min(replace_pos);
|
pos = pos.min(replace_pos);
|
||||||
|
replaced = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.add(classes, pos);
|
if replaced {
|
||||||
|
self.add(normalized.as_ref().split_ascii_whitespace(), pos);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ClassesOp::Toggle => {
|
ClassesOp::Toggle => {
|
||||||
for class in classes.split_ascii_whitespace() {
|
for class in normalized.as_ref().split_ascii_whitespace() {
|
||||||
let class = class.to_ascii_lowercase();
|
if let Some(pos) = self.0.iter().position(|c| c == class) {
|
||||||
if let Some(pos) = self.0.iter().position(|c| c == &class) {
|
|
||||||
self.0.remove(pos);
|
self.0.remove(pos);
|
||||||
} else {
|
} else {
|
||||||
self.0.push(class);
|
self.0.push(class.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClassesOp::Set => {
|
ClassesOp::Set => {
|
||||||
self.0.clear();
|
self.0.clear();
|
||||||
self.add(classes, 0);
|
self.add(normalized.as_ref().split_ascii_whitespace(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,11 +148,14 @@ impl Classes {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn add(&mut self, classes: &str, mut pos: usize) {
|
fn add<'a, I>(&mut self, classes: I, mut pos: usize)
|
||||||
for class in classes.split_ascii_whitespace() {
|
where
|
||||||
let class = class.to_ascii_lowercase();
|
I: IntoIterator<Item = &'a str>,
|
||||||
|
{
|
||||||
|
for class in classes {
|
||||||
// Inserción segura descartando duplicados.
|
// 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() {
|
if pos >= self.0.len() {
|
||||||
self.0.push(class);
|
self.0.push(class);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -159,32 +177,25 @@ impl Classes {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Devuelve `true` si **una única clase** está presente.
|
/// Devuelve `true` si la clase o **todas** las clases indicadas están presentes.
|
||||||
///
|
pub fn contains(&self, classes: impl AsRef<str>) -> bool {
|
||||||
/// Si necesitas comprobar varias clases, usa [`contains_all()`](Self::contains_all) o
|
let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else {
|
||||||
/// [`contains_any()`](Self::contains_any).
|
return false;
|
||||||
pub fn contains(&self, class: impl AsRef<str>) -> bool {
|
};
|
||||||
self.contains_class(class.as_ref())
|
normalized
|
||||||
}
|
|
||||||
|
|
||||||
/// Devuelve `true` si **todas** las clases indicadas están presentes.
|
|
||||||
pub fn contains_all(&self, classes: impl AsRef<str>) -> bool {
|
|
||||||
classes
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.split_ascii_whitespace()
|
.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<str>) -> bool {
|
pub fn contains_any(&self, classes: impl AsRef<str>) -> bool {
|
||||||
classes
|
let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
normalized
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.split_ascii_whitespace()
|
.split_ascii_whitespace()
|
||||||
.any(|class| self.contains_class(class))
|
.any(|class| self.0.iter().any(|c| c == class))
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn contains_class(&self, class: &str) -> bool {
|
|
||||||
self.0.iter().any(|c| c.eq_ignore_ascii_case(class))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
128
src/util.rs
128
src/util.rs
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use crate::trace;
|
use crate::trace;
|
||||||
|
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -24,6 +25,133 @@ pub use pagetop_minimal::paste;
|
||||||
|
|
||||||
// **< FUNCIONES ÚTILES >***************************************************************************
|
// **< 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<Cow<'a, str>, 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<Cow<'a, str>> {
|
||||||
|
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.
|
/// 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
|
/// - Si la ruta es relativa, se resuelve respecto al directorio del proyecto según la variable de
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@ use pagetop::prelude::*;
|
||||||
|
|
||||||
fn assert_classes(c: &Classes, expected: Option<&str>) {
|
fn assert_classes(c: &Classes, expected: Option<&str>) {
|
||||||
let got = c.get();
|
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) >********************************************************
|
// **< Construction & invariants (new/get) >********************************************************
|
||||||
|
|
@ -48,6 +54,12 @@ async fn classes_add_same_tokens() {
|
||||||
assert_classes(&c, Some("a b"));
|
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]
|
#[pagetop::test]
|
||||||
async fn classes_prepend_inserts_at_front_preserving_new_order() {
|
async fn classes_prepend_inserts_at_front_preserving_new_order() {
|
||||||
let c = Classes::new("c d").with_classes(ClassesOp::Prepend, "A b");
|
let c = Classes::new("c d").with_classes(ClassesOp::Prepend, "A b");
|
||||||
|
|
@ -133,9 +145,9 @@ async fn classes_replace_removes_targets_and_inserts_new_at_min_position() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[pagetop::test]
|
#[pagetop::test]
|
||||||
async fn classes_replace_when_none_found_appends_at_end() {
|
async fn classes_replace_when_none_found_does_nothing() {
|
||||||
let c = Classes::new("a b").with_classes(ClassesOp::Replace("x y".into()), "c d");
|
let c = Classes::new("a b").with_classes(ClassesOp::Replace("x y".into()), "c d");
|
||||||
assert_classes(&c, Some("a b c d"));
|
assert_classes(&c, Some("a b"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[pagetop::test]
|
#[pagetop::test]
|
||||||
|
|
@ -163,6 +175,12 @@ async fn classes_replace_ignores_target_whitespace_and_repetition() {
|
||||||
assert_classes(&c, Some("a x y c"));
|
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) >*************************************************************************
|
// **< Queries (contains) >*************************************************************************
|
||||||
|
|
||||||
#[pagetop::test]
|
#[pagetop::test]
|
||||||
|
|
@ -177,15 +195,31 @@ async fn classes_contains_single() {
|
||||||
async fn classes_contains_all_and_any() {
|
async fn classes_contains_all_and_any() {
|
||||||
let c = Classes::new("btn btn-primary active");
|
let c = Classes::new("btn btn-primary active");
|
||||||
|
|
||||||
assert!(c.contains_all("btn active"));
|
assert!(c.contains("btn active"));
|
||||||
assert!(c.contains_all("BTN BTN-PRIMARY"));
|
assert!(c.contains("BTN BTN-PRIMARY"));
|
||||||
assert!(!c.contains_all("btn missing"));
|
assert!(!c.contains("btn missing"));
|
||||||
|
|
||||||
assert!(c.contains_any("missing active"));
|
assert!(c.contains_any("missing active"));
|
||||||
assert!(c.contains_any("BTN-PRIMARY missing"));
|
assert!(c.contains_any("BTN-PRIMARY missing"));
|
||||||
assert!(!c.contains_any("missing other"));
|
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) >*************************************
|
// **< Properties / regression (combined sequences, ordering) >*************************************
|
||||||
|
|
||||||
#[pagetop::test]
|
#[pagetop::test]
|
||||||
|
|
|
||||||
258
tests/util.rs
258
tests/util.rs
|
|
@ -1,8 +1,264 @@
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use std::{env, fs, io};
|
use std::{borrow::Cow, env, fs, io};
|
||||||
use tempfile::TempDir;
|
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)]
|
#[cfg(unix)]
|
||||||
mod unix {
|
mod unix {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue