use crate::tests::assert_dom::virtual_dom::VirtualNode;
pub(crate) fn query_css<'a>(root: &'a VirtualNode, selector: &str) -> Vec<&'a VirtualNode> {
let selector = selector.trim();
let mut seen: Vec<*const VirtualNode> = Vec::new();
query_descendant_or_self(root, selector)
.into_iter()
.filter(|node| {
let ptr = *node as *const VirtualNode;
if seen.contains(&ptr) {
false
} else {
seen.push(ptr);
true
}
})
.collect()
}
fn find_descendant_combinator_space(pattern: &str) -> Option<usize> {
for (i, ch) in pattern.char_indices() {
if ch != ' ' {
continue;
}
let rest = pattern[i..].trim_start();
if rest.starts_with('>') || rest.starts_with('+') {
continue;
}
let prev = pattern[..i].trim_end();
if prev.is_empty() || prev.ends_with('>') || prev.ends_with('+') {
continue;
}
return Some(i);
}
None
}
fn query_descendant_or_self<'a>(node: &'a VirtualNode, pattern: &str) -> Vec<&'a VirtualNode> {
let space_pos = find_descendant_combinator_space(pattern);
let gt_pos = pattern.find('>');
if let Some(space) = space_pos
&& (gt_pos.is_none() || space < gt_pos.unwrap())
{
let (first, rest) = pattern.split_at(space);
let first = first.trim();
let rest = rest.trim();
let mut results = Vec::new();
collect_descendants_matching(node, first, &mut results);
let mut final_results = Vec::new();
for matched_node in results {
for child in &matched_node.children {
let descendants = query_descendant_or_self(child, rest);
final_results.extend(descendants);
}
}
return final_results;
}
if let Some((first, rest)) = pattern.split_once('>') {
let first = first.trim();
let rest = rest.trim();
let mut results = Vec::new();
collect_descendants_matching(node, first, &mut results);
let mut final_results = Vec::new();
for matched_node in results {
let children_matches = query_with_direct_child_constraint(matched_node, rest);
final_results.extend(children_matches);
}
return final_results;
}
if let Some((first, rest)) = pattern.split_once('+') {
let first = first.trim();
let rest = rest.trim();
let mut results = Vec::new();
collect_descendants_matching_with_siblings(node, first, &mut results);
let mut final_results = Vec::new();
for (_matched_node, parent, child_index) in results {
if let Some(next_sibling) = parent.children.get(child_index + 1) {
if rest.contains('>') || rest.contains('+') {
let further = query_descendant_or_self(next_sibling, rest);
final_results.extend(further);
} else {
if matches_selector_with_context(next_sibling, rest, Some(parent)) {
final_results.push(next_sibling);
}
}
}
}
return final_results;
}
let mut results = Vec::new();
collect_descendants_matching(node, pattern.trim(), &mut results);
results
}
fn collect_descendants_matching<'a>(
node: &'a VirtualNode,
selector: &str,
results: &mut Vec<&'a VirtualNode>,
) {
collect_descendants_matching_with_parent(node, selector, None, results);
}
fn collect_descendants_matching_with_parent<'a>(
node: &'a VirtualNode,
selector: &str,
parent: Option<&'a VirtualNode>,
results: &mut Vec<&'a VirtualNode>,
) {
if matches_selector_with_context(node, selector, parent) {
results.push(node);
}
for child in &node.children {
collect_descendants_matching_with_parent(child, selector, Some(node), results);
}
}
fn collect_descendants_matching_with_siblings<'a>(
node: &'a VirtualNode,
selector: &str,
results: &mut Vec<(&'a VirtualNode, &'a VirtualNode, usize)>,
) {
for (index, child) in node.children.iter().enumerate() {
if matches_selector_with_context(child, selector, Some(node)) {
results.push((child, node, index));
}
collect_descendants_matching_with_siblings(child, selector, results);
}
}
fn query_with_direct_child_constraint<'a>(
node: &'a VirtualNode,
pattern: &str,
) -> Vec<&'a VirtualNode> {
let plus_pos = pattern.find('+');
let gt_pos = pattern.find('>');
let space_pos = find_descendant_combinator_space(pattern);
if let Some(space) = space_pos
&& gt_pos.is_none_or(|gt| space < gt)
&& plus_pos.is_none_or(|plus| space < plus)
{
let (first, rest) = pattern.split_at(space);
let first = first.trim();
let rest = rest.trim();
let mut results = Vec::new();
for child in &node.children {
if matches_selector_with_context(child, first, Some(node)) {
for grandchild in &child.children {
results.extend(query_descendant_or_self(grandchild, rest));
}
}
}
return results;
}
if let Some(gt) = gt_pos
&& (plus_pos.is_none() || gt < plus_pos.unwrap())
{
let (first, rest) = pattern.split_at(gt);
let first = first.trim();
let rest = rest[1..].trim();
let mut results = Vec::new();
for child in &node.children {
if matches_selector_with_context(child, first, Some(node)) {
let further_matches = query_with_direct_child_constraint(child, rest);
results.extend(further_matches);
}
}
return results;
}
if let Some((first, rest)) = pattern.split_once('+') {
let first = first.trim();
let rest = rest.trim();
let mut results = Vec::new();
for child in &node.children {
if matches_selector_with_context(child, first, Some(node)) {
let child_index = node
.children
.iter()
.position(|c| std::ptr::eq(c as *const _, child as *const _));
if let Some(idx) = child_index
&& let Some(next_sibling) = node.children.get(idx + 1)
{
if rest.contains('>') || rest.contains('+') {
let (next_part, remaining) = if let Some(pos) = rest.find('>') {
(rest[..pos].trim(), Some(rest[pos + 1..].trim()))
} else if let Some(pos) = rest.find('+') {
(rest[..pos].trim(), Some(rest[pos + 1..].trim()))
} else {
(rest, None)
};
if matches_selector_with_context(next_sibling, next_part, Some(node)) {
if let Some(remaining) = remaining {
let further =
query_with_direct_child_constraint(next_sibling, remaining);
results.extend(further);
} else {
results.push(next_sibling);
}
}
} else {
if matches_selector_with_context(next_sibling, rest, Some(node)) {
results.push(next_sibling);
}
}
}
}
}
results
} else {
let mut results = Vec::new();
for child in &node.children {
if matches_selector_with_context(child, pattern, Some(node)) {
results.push(child);
}
}
results
}
}
fn matches_selector_with_context(
node: &VirtualNode,
selector: &str,
parent: Option<&VirtualNode>,
) -> bool {
let selector = selector.trim();
let (selector_without_pseudo, pseudo_selector) = if let Some(colon_pos) = selector.find(':') {
(&selector[..colon_pos], Some(&selector[colon_pos + 1..]))
} else {
(selector, None)
};
let (base_selector, predicate) = if let Some(bracket_pos) = selector_without_pseudo.find('[') {
(
&selector_without_pseudo[..bracket_pos],
Some(&selector_without_pseudo[bracket_pos..]),
)
} else {
(selector_without_pseudo, None)
};
let (tag_part, class_selectors) = if let Some(dot_pos) = base_selector.find('.') {
(&base_selector[..dot_pos], Some(&base_selector[dot_pos..]))
} else {
(base_selector, None)
};
let (tag_part, id_selector) = if let Some(hash_pos) = tag_part.find('#') {
(&tag_part[..hash_pos], Some(&tag_part[hash_pos + 1..]))
} else {
(tag_part, None)
};
if tag_part == "*" {
if let Some(predicate) = predicate
&& !matches_predicate(node, predicate)
{
return false;
}
} else if !tag_part.is_empty() {
if node.tag != tag_part {
return false;
}
}
if let Some(id) = id_selector
&& node.id.as_deref() != Some(id)
{
return false;
}
if let Some(classes) = class_selectors {
let class_names: Vec<&str> = classes[1..].split('.').filter(|s| !s.is_empty()).collect();
if !class_names
.iter()
.all(|class_name| node.classes.iter().any(|c| c == class_name))
{
return false;
}
}
if let Some(predicate) = predicate
&& !matches_predicate(node, predicate)
{
return false;
}
if let Some(pseudo) = pseudo_selector
&& !matches_pseudo_selector(node, pseudo, parent)
{
return false;
}
true
}
fn matches_pseudo_selector(node: &VirtualNode, pseudo: &str, parent: Option<&VirtualNode>) -> bool {
split_pseudo_chain(pseudo.trim())
.iter()
.all(|part| matches_single_pseudo(node, part, parent))
}
fn split_pseudo_chain(pseudo: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth = 0usize;
let mut start = 0;
for (i, ch) in pseudo.char_indices() {
match ch {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
':' if depth == 0 => {
parts.push(&pseudo[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&pseudo[start..]);
parts.into_iter().filter(|p| !p.is_empty()).collect()
}
fn matches_single_pseudo(node: &VirtualNode, pseudo: &str, parent: Option<&VirtualNode>) -> bool {
let pseudo = pseudo.trim();
if let Some(inner) = pseudo.strip_prefix("not(") {
if let Some(inner) = inner.strip_suffix(')') {
return !matches_inner_not_selector(node, inner.trim());
}
return false; }
if let Some(arg) = pseudo.strip_prefix("nth-child(") {
if let Some(arg) = arg.strip_suffix(')')
&& let Ok(n) = arg.trim().parse::<usize>()
&& let Some(parent) = parent
{
return parent
.children
.get(n.wrapping_sub(1))
.is_some_and(|child| std::ptr::eq(child as *const _, node as *const _));
}
return false; }
match pseudo {
"first-of-type" => {
if let Some(parent) = parent {
for child in &parent.children {
if child.tag == node.tag {
return std::ptr::eq(child as *const _, node as *const _);
}
}
}
true
}
"empty" => {
node.children.is_empty()
&& (node.text.is_none() || node.text.as_ref().is_some_and(|t| t.is_empty()))
}
_ => false, }
}
fn matches_inner_not_selector(node: &VirtualNode, selector: &str) -> bool {
let selector = selector.trim();
if selector.starts_with('[') && selector.ends_with(']') {
let inner = &selector[1..selector.len() - 1];
return matches_single_predicate(node, inner);
}
if let Some(class_name) = selector.strip_prefix('.') {
return node.classes.iter().any(|c| c == class_name);
}
if let Some(id) = selector.strip_prefix('#') {
return node.id.as_deref() == Some(id);
}
if !selector.is_empty() && !selector.contains('[') {
return node.tag == selector;
}
false
}
fn matches_predicate(node: &VirtualNode, predicate: &str) -> bool {
let mut predicate = predicate.trim();
while !predicate.is_empty() {
if let Some(bracket_start) = predicate.find('[') {
if let Some(bracket_end) = predicate[bracket_start..].find(']') {
let bracket_end = bracket_start + bracket_end;
let single_pred = &predicate[bracket_start + 1..bracket_end];
if !matches_single_predicate(node, single_pred) {
return false;
}
predicate = predicate[bracket_end + 1..].trim();
} else {
return false;
}
} else {
break;
}
}
true
}
fn matches_single_predicate(node: &VirtualNode, predicate: &str) -> bool {
let predicate = predicate.trim();
if let Some(rest) = predicate.strip_prefix("text()") {
let rest = rest.trim();
if let Some(value_part) = rest.strip_prefix('=').map(|s| s.trim()) {
if let Some(value) = value_part.strip_prefix('\'') {
if let Some(value) = value.strip_suffix('\'') {
let unescaped = unescape_css_string(value);
return node.text.as_deref() == Some(&unescaped);
}
}
else if let Some(value) = value_part.strip_prefix('"')
&& let Some(value) = value.strip_suffix('"')
{
let unescaped = unescape_css_string(value);
return node.text.as_deref() == Some(&unescaped);
}
}
return false;
}
if let Some(attr_part) = predicate.strip_prefix('@') {
if let Some((attr_name, value_part)) = attr_part.split_once('=') {
let attr_name = attr_name.trim();
let value = value_part
.trim()
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(value_part.trim());
match attr_name {
"class" => return node.classes.iter().any(|c| c == value),
"id" => return node.id.as_deref() == Some(value),
_ => {
return node.attributes.get(attr_name).map(|v| v.as_str()) == Some(value);
}
}
} else {
let attr_name = attr_part.trim();
match attr_name {
"class" => return !node.classes.is_empty(),
"id" => return node.id.is_some(),
_ => return node.attributes.contains_key(attr_name),
}
}
}
if !predicate.contains('=') && !predicate.contains('(') {
let attr_name = predicate.trim();
match attr_name {
"class" => return !node.classes.is_empty(),
"id" => return node.id.is_some(),
_ => return node.attributes.contains_key(attr_name),
}
}
if let Some((attr_name, value_part)) = predicate.split_once("*=") {
let attr_name = attr_name.trim();
let value = unquote_attr_value(value_part);
return match attr_name {
"class" => node.classes.join(" ").contains(value.as_str()),
"id" => node
.id
.as_deref()
.is_some_and(|id| id.contains(value.as_str())),
_ => node
.attributes
.get(attr_name)
.is_some_and(|v| v.contains(&value)),
};
}
if let Some((attr_name, value_part)) = predicate.split_once('=') {
let attr_name = attr_name.trim();
let value = unquote_attr_value(value_part);
match attr_name {
"class" => {
return value
.split_whitespace()
.all(|c| node.classes.iter().any(|nc| nc == c));
}
"id" => return node.id.as_deref() == Some(value.as_str()),
_ => return node.attributes.get(attr_name).map(|v| v.as_str()) == Some(value.as_str()),
}
}
true
}
fn unquote_attr_value(value_part: &str) -> String {
let trimmed = value_part.trim();
let unquoted = trimmed
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| {
trimmed
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
})
.unwrap_or(trimmed);
unescape_css_string(unquoted)
}
fn unescape_css_string(s: &str) -> String {
let mut result = String::new();
let mut chars = s.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
if let Some(next) = chars.next() {
match next {
'n' => result.push('\n'),
't' => result.push('\t'),
'r' => result.push('\r'),
'\\' => result.push('\\'),
'\'' => result.push('\''),
'"' => result.push('"'),
_ => {
result.push('\\');
result.push(next);
}
}
} else {
result.push('\\');
}
} else {
result.push(ch);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::{assert_dom::virtual_dom::ToVirtualDom, prelude::*};
#[test]
fn query_all_paragraphs() {
let doc = Parser::default().parse("Para 1\n\nPara 2\n\nPara 3");
let vdom = doc.to_virtual_dom();
let paras = query_css(&vdom, "p");
assert_eq!(paras.len(), 3);
}
#[test]
fn query_list_items() {
let doc = Parser::default().parse("* item 1\n* item 2\n* item 3");
let vdom = doc.to_virtual_dom();
let uls = query_css(&vdom, "ul");
assert_eq!(uls.len(), 1);
let lis = query_css(&vdom, "li");
assert_eq!(lis.len(), 3);
let ul_lis = query_css(&vdom, "ul li");
assert_eq!(ul_lis.len(), 3);
}
#[test]
fn query_first_of_type() {
let doc = Parser::default().parse("* item 1\n* item 2\n* item 3");
let vdom = doc.to_virtual_dom();
let first_li = query_css(&vdom, "li:first-of-type");
assert_eq!(first_li.len(), 1);
assert_eq!(first_li[0].children.len(), 1); assert_eq!(first_li[0].children[0].tag, "p");
assert_eq!(first_li[0].children[0].text.as_deref(), Some("item 1"));
}
#[test]
fn query_direct_children() {
let doc = Parser::default().parse("* item 1\n\n para\n* item 2");
let vdom = doc.to_virtual_dom();
let children = query_css(&vdom, "li:first-of-type > *");
assert!(!children.is_empty()); }
#[test]
fn query_first_of_type_with_direct_child() {
let doc = Parser::default().parse("* item 1\n\n para\n* item 2");
let vdom = doc.to_virtual_dom();
let paras = query_css(&vdom, "li:first-of-type > p");
assert!(!paras.is_empty());
}
#[test]
fn query_plus_with_direct_child() {
let doc = Parser::default().parse("* bullet 1\n. numbered 1.1");
let vdom = doc.to_virtual_dom();
let matches = query_css(&vdom, "li > p + .olist");
assert_eq!(
matches.len(),
1,
"Should find 1 .olist that is adjacent sibling of p inside li"
);
}
#[test]
fn query_nth_child_and_pseudo_chain() {
let doc = Parser::default().parse("[format=csv]\n|===\na,b,c\n1,2,\n|===");
let vdom = doc.to_virtual_dom();
assert_eq!(query_css(&vdom, "tbody > tr").len(), 2);
assert_eq!(query_css(&vdom, "tbody > tr:nth-child(1) > td").len(), 3);
assert_eq!(query_css(&vdom, "tbody > tr:nth-child(2) > td").len(), 3);
assert_eq!(
query_css(&vdom, "tbody > tr:nth-child(2) > td:nth-child(3):empty").len(),
1
);
assert_eq!(
query_css(&vdom, "tbody > tr:nth-child(1) > td:nth-child(3):empty").len(),
0
);
assert_eq!(query_css(&vdom, "tbody > tr:nth-child(3)").len(), 0);
}
#[test]
fn query_child_chain_then_descendant() {
let doc = Parser::default().parse("[cols=\"1a\"]\n|===\n|AsciiDoc content\n|===");
let vdom = doc.to_virtual_dom();
assert_eq!(query_css(&vdom, "table > tbody > tr > td").len(), 1);
assert_eq!(
query_css(&vdom, "table > tbody > tr > td .paragraph").len(),
1
);
assert_eq!(query_css(&vdom, "td .content > .paragraph").len(), 1);
}
#[test]
fn query_full_ulist_selector() {
let doc = Parser::default().parse("* bullet 1\n. numbered 1.1");
let vdom = doc.to_virtual_dom();
let matches = query_css(&vdom, ".ulist > ul > li > p + .olist");
assert_eq!(matches.len(), 1, "Should find 1 .olist with full selector");
}
#[test]
fn query_with_arbitrary_attribute() {
let doc = Parser::default().parse("* Foo\n[start=2]\n. Boo\n* Blech\n");
let vdom = doc.to_virtual_dom();
let result = query_css(&vdom, "ol[@start=\"2\"]");
assert_eq!(result.len(), 1, "Should find one ol with start=2");
assert_eq!(result[0].attributes.get("start"), Some(&"2".to_string()));
}
#[test]
fn query_attribute_substring_selector() {
let node = VirtualNode::new("table")
.with_classes(["tableblock", "fit-content"])
.with_id("results")
.with_attribute("style", "width: 50%;");
assert_eq!(query_css(&node, "table[class*=\"fit\"]").len(), 1);
assert_eq!(query_css(&node, "table[class*=\"block\"]").len(), 1);
assert_eq!(query_css(&node, "table[class*=\"nope\"]").len(), 0);
assert_eq!(query_css(&node, "table[id*=\"sult\"]").len(), 1);
assert_eq!(query_css(&node, "table[id*=\"nope\"]").len(), 0);
assert_eq!(query_css(&node, "table[style*=\"width\"]").len(), 1);
assert_eq!(query_css(&node, "table[style*=\"height\"]").len(), 0);
}
#[test]
fn query_attribute_existence() {
let doc = Parser::default().parse("* Foo\n[start=2]\n. Boo\n. Blech\n");
let vdom = doc.to_virtual_dom();
let with_start = query_css(&vdom, "ol[start]");
assert_eq!(
with_start.len(),
1,
"Should find one ol with start attribute"
);
let all_ol = query_css(&vdom, "ol");
assert_eq!(all_ol.len(), 1, "Should find one ol element total");
let with_start_xpath = query_css(&vdom, "ol[@start]");
assert_eq!(
with_start_xpath.len(),
1,
"Should find one ol with start attribute (XPath-style)"
);
}
#[test]
fn query_not_selector() {
let doc = Parser::default().parse("[glossary]\nterm 1:: def 1\nterm 2:: def 2\n");
let vdom = doc.to_virtual_dom();
let dt_no_class = query_css(&vdom, "dt:not([class])");
assert_eq!(
dt_no_class.len(),
2,
"Should find 2 dt elements without class"
);
let all_dt = query_css(&vdom, "dt");
assert_eq!(all_dt.len(), 2, "Should find 2 dt elements total");
}
#[test]
fn query_not_selector_with_class() {
let doc = Parser::default().parse("* item");
let vdom = doc.to_virtual_dom();
let ulist_divs = query_css(&vdom, "div.ulist");
assert_eq!(ulist_divs.len(), 1, "Should find 1 .ulist div");
let ulist_with_not = query_css(&vdom, ".ulist:not(.olist)");
assert_eq!(
ulist_with_not.len(),
1,
"Should find 1 .ulist that is not .olist"
);
let excluded = query_css(&vdom, "div:not(.nonexistent)");
assert!(
!excluded.is_empty(),
"Should find divs that don't have .nonexistent class"
);
}
}