diff --git a/Cargo.lock b/Cargo.lock index 62933c2..3b0a4fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -29,6 +38,12 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + [[package]] name = "once_cell" version = "1.19.0" @@ -53,6 +68,35 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" + [[package]] name = "syn" version = "2.0.74" @@ -68,9 +112,32 @@ dependencies = [ name = "testing-library-dom" version = "0.0.2" dependencies = [ + "regex", + "thiserror", + "wasm-bindgen", "web-sys", ] +[[package]] +name = "thiserror" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.12" diff --git a/Cargo.toml b/Cargo.toml index d8e5785..524b0f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,5 +10,8 @@ repository = "https://github.com/RustForWeb/radix" version = "0.0.2" [workspace.dependencies] -web-sys = "0.3.69" +regex = "1.10.6" +thiserror = "1.0.63" +web-sys = "0.3.70" +wasm-bindgen = "0.2.93" wasm-bindgen-test = "0.3.42" diff --git a/packages/dom/Cargo.toml b/packages/dom/Cargo.toml index 0ac03e8..39bc850 100644 --- a/packages/dom/Cargo.toml +++ b/packages/dom/Cargo.toml @@ -10,4 +10,11 @@ repository.workspace = true version.workspace = true [dependencies] -web-sys.workspace = true +regex.workspace = true +thiserror.workspace = true +wasm-bindgen.workspace = true +web-sys = { workspace = true, features = [ + "Element", + "HtmlElement", + "NodeList", +] } diff --git a/packages/dom/src/error.rs b/packages/dom/src/error.rs new file mode 100644 index 0000000..4f00670 --- /dev/null +++ b/packages/dom/src/error.rs @@ -0,0 +1,14 @@ +use thiserror::Error; +use web_sys::wasm_bindgen::JsValue; + +#[derive(Debug, Error)] +pub enum QueryError { + #[error("invalid configuration: {0}")] + Configuration(String), + #[error("{0:?}")] + JsError(JsValue), + #[error("{0}")] + NoElements(String), + #[error("{0}")] + MultipleElements(String), +} diff --git a/packages/dom/src/lib.rs b/packages/dom/src/lib.rs index 8b13789..9d049bc 100644 --- a/packages/dom/src/lib.rs +++ b/packages/dom/src/lib.rs @@ -1 +1,11 @@ +mod error; +mod matches; +pub mod queries; +pub mod query_helpers; +mod types; +mod util; +pub use matches::get_default_normalizer; +pub use queries::*; +pub use query_helpers::*; +pub use types::*; diff --git a/packages/dom/src/matches.rs b/packages/dom/src/matches.rs new file mode 100644 index 0000000..319f4b0 --- /dev/null +++ b/packages/dom/src/matches.rs @@ -0,0 +1,105 @@ +use regex::Regex; +use web_sys::Element; + +use crate::{ + error::QueryError, DefaultNormalizerOptions, Matcher, NormalizerFn, NormalizerOptions, +}; + +pub fn fuzzy_matches( + text_to_match: Option, + node: Option<&Element>, + matcher: &Matcher, + normalizer: &NormalizerFn, +) -> bool { + if let Some(text_to_match) = text_to_match { + let normalized_text = normalizer(text_to_match); + + match matcher { + Matcher::Function(matcher) => matcher(normalized_text, node), + Matcher::Regex(matcher) => match_regex(matcher, normalized_text), + Matcher::Number(matcher) => normalized_text == matcher.to_string(), + Matcher::String(matcher) => normalized_text.to_lowercase() == matcher.to_lowercase(), + } + } else { + false + } +} + +pub fn matches( + text_to_match: Option, + node: Option<&Element>, + matcher: &Matcher, + normalizer: &NormalizerFn, +) -> bool { + if let Some(text_to_match) = text_to_match { + let normalized_text = normalizer(text_to_match); + + match matcher { + Matcher::Function(matcher) => matcher(normalized_text, node), + Matcher::Regex(matcher) => match_regex(matcher, normalized_text), + Matcher::Number(matcher) => normalized_text == matcher.to_string(), + Matcher::String(matcher) => normalized_text == *matcher, + } + } else { + false + } +} + +pub fn get_default_normalizer( + DefaultNormalizerOptions { + trim, + collapse_whitespace, + }: DefaultNormalizerOptions, +) -> Box { + let trim = trim.unwrap_or(true); + let collapse_whitespace = collapse_whitespace.unwrap_or(true); + + Box::new(move |text| { + let mut normalized_text = text; + + if trim { + normalized_text = normalized_text.trim().to_string(); + } + + if collapse_whitespace { + normalized_text = Regex::new(r"\s+") + .expect("Regex should be valid.") + .replace_all(&normalized_text, " ") + .to_string(); + } + + normalized_text + }) +} + +/// Constructs a normalizer to pass to matches functions. +pub fn make_normalizer( + NormalizerOptions { + trim, + collapse_whitespace, + normalizer, + }: NormalizerOptions, +) -> Result, QueryError> { + if let Some(normalizer) = normalizer { + if trim.is_some() || collapse_whitespace.is_some() { + Err(QueryError::Configuration("\n\ + `trim` and `collapse_whitespace` are not supported with a normalizer. \n\ + If you want to use the default trim and `collapse_whitespace logic in your normalizer, \n\ + use `get_default_normalizer(DefaultNormalizerOptions {trim, collapse_whitespace})` and compose that into your normalizer.\ + ".into())) + } else { + Ok(normalizer) + } + } else { + // No custom normalizer specified. Just use default. + Ok(get_default_normalizer(DefaultNormalizerOptions { + trim, + collapse_whitespace, + })) + } +} + +fn match_regex(matcher: &Regex, text: String) -> bool { + // TODO: if statement? + matcher.is_match(&text) +} diff --git a/packages/dom/src/queries.rs b/packages/dom/src/queries.rs new file mode 100644 index 0000000..8106c06 --- /dev/null +++ b/packages/dom/src/queries.rs @@ -0,0 +1,3 @@ +mod alt_text; + +pub use alt_text::*; diff --git a/packages/dom/src/queries/alt_text.rs b/packages/dom/src/queries/alt_text.rs new file mode 100644 index 0000000..3196bdb --- /dev/null +++ b/packages/dom/src/queries/alt_text.rs @@ -0,0 +1,33 @@ +use regex::Regex; +use web_sys::{Element, HtmlElement}; + +use crate::{ + error::QueryError, + query_all_by_attribute, + types::{Matcher, MatcherOptions}, +}; + +pub fn query_all_by_alt_text( + container: HtmlElement, + alt: &Matcher, + options: MatcherOptions, +) -> Result, QueryError> { + // check_container_type(container); + + let valid_tag_regex = Regex::new(r"^(img|input|area|.+-.+)$").expect("Regex should be valid."); + + Ok( + query_all_by_attribute("alt".to_string(), container, alt, options)? + .into_iter() + .filter(|node| valid_tag_regex.is_match(&node.tag_name())) + .collect(), + ) +} + +fn _get_multiple_error(_c: Option, alt: String) -> String { + format!("Found multiple elements with alt text: {alt}") +} + +fn _get_missing_error(_c: Option, alt: String) -> String { + format!("Unable to find an element with alt text: {alt}") +} diff --git a/packages/dom/src/query_helpers.rs b/packages/dom/src/query_helpers.rs new file mode 100644 index 0000000..6dfa550 --- /dev/null +++ b/packages/dom/src/query_helpers.rs @@ -0,0 +1,65 @@ +use web_sys::HtmlElement; + +use crate::{ + error::QueryError, + matches::{fuzzy_matches, make_normalizer, matches}, + util::node_list_to_vec, + Matcher, MatcherOptions, NormalizerOptions, +}; + +pub fn query_all_by_attribute( + attribute: String, + container: HtmlElement, + text: &Matcher, + MatcherOptions { + exact, + trim, + collapse_whitespace, + normalizer, + .. + }: MatcherOptions, +) -> Result, QueryError> { + let exact = exact.unwrap_or(true); + + let matcher = match exact { + true => matches, + false => fuzzy_matches, + }; + let match_normalizer = make_normalizer(NormalizerOptions { + trim, + collapse_whitespace, + normalizer, + })?; + + Ok(node_list_to_vec::( + container + .query_selector_all(&format!("[{attribute}]")) + .map_err(QueryError::JsError)?, + ) + .into_iter() + .filter(|node| { + matcher( + node.get_attribute(&attribute), + Some(node), + text, + match_normalizer.as_ref(), + ) + }) + .collect()) +} + +pub fn query_by_attribute( + attribute: String, + container: HtmlElement, + text: &Matcher, + options: MatcherOptions, +) -> Result, QueryError> { + let mut els = query_all_by_attribute(attribute.clone(), container, text, options)?; + if els.len() > 1 { + Err(QueryError::MultipleElements(format!( + "Found multiple elements by [{attribute}={text}]" + ))) + } else { + Ok(els.pop()) + } +} diff --git a/packages/dom/src/types.rs b/packages/dom/src/types.rs new file mode 100644 index 0000000..a0f7c06 --- /dev/null +++ b/packages/dom/src/types.rs @@ -0,0 +1,3 @@ +mod matches; + +pub use matches::*; diff --git a/packages/dom/src/types/matches.rs b/packages/dom/src/types/matches.rs new file mode 100644 index 0000000..8391b1a --- /dev/null +++ b/packages/dom/src/types/matches.rs @@ -0,0 +1,54 @@ +use std::fmt::Display; + +use regex::Regex; +use web_sys::Element; + +pub type MatcherFunction = dyn Fn(String, Option<&Element>) -> bool; + +pub enum Matcher { + Function(Box), + Regex(Regex), + Number(f64), + String(String), +} + +impl Display for Matcher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::Function(_) => "MatcherFn".to_string(), + Self::Regex(regex) => regex.to_string(), + Self::Number(n) => format!("\n{n}\""), + Self::String(s) => format!("\"{s}\""), + } + ) + } +} + +// pub enum ByRoleMatcher + +pub type NormalizerFn = dyn Fn(String) -> String; + +#[derive(Default)] +pub struct NormalizerOptions { + pub trim: Option, + pub collapse_whitespace: Option, + pub normalizer: Option>, +} + +#[derive(Default)] +pub struct MatcherOptions { + pub exact: Option, + pub trim: Option, + pub collapse_whitespace: Option, + pub normalizer: Option>, + pub suggest: Option, +} + +#[derive(Default)] +pub struct DefaultNormalizerOptions { + pub trim: Option, + pub collapse_whitespace: Option, +} diff --git a/packages/dom/src/util.rs b/packages/dom/src/util.rs new file mode 100644 index 0000000..73e1b74 --- /dev/null +++ b/packages/dom/src/util.rs @@ -0,0 +1,20 @@ +use wasm_bindgen::JsCast; +use web_sys::NodeList; + +pub fn node_list_to_vec(node_list: NodeList) -> Vec { + let mut result = Vec::with_capacity( + node_list + .length() + .try_into() + .expect("usize should be at least u32."), + ); + for i in 0..node_list.length() { + result.push( + node_list + .get(i) + .expect("Node should exist.") + .unchecked_into::(), + ); + } + result +}