Skip to main content

fhp_selector/
lib.rs

1//! CSS selector and XPath engine for the SIMD-optimized HTML parser.
2//!
3//! Provides CSS selector parsing, XPath evaluation, and a convenience API
4//! for querying a parsed [`fhp_tree::Document`].
5//!
6//! # Quick Start — CSS
7//!
8//! ```
9//! use fhp_tree::parse;
10//! use fhp_selector::Selectable;
11//!
12//! let doc = parse("<div><p class=\"intro\">Hello</p></div>").unwrap();
13//! let sel = doc.select("p.intro").unwrap();
14//! assert_eq!(sel.len(), 1);
15//! assert_eq!(sel.text(), "Hello");
16//! ```
17//!
18//! # Quick Start — XPath
19//!
20//! ```
21//! use fhp_tree::parse;
22//! use fhp_selector::Selectable;
23//! use fhp_selector::xpath::ast::XPathResult;
24//!
25//! let doc = parse("<div><p>Hello</p></div>").unwrap();
26//! let result = doc.xpath("//p/text()").unwrap();
27//! match result {
28//!     XPathResult::Strings(texts) => assert_eq!(texts[0], "Hello"),
29//!     _ => panic!("expected strings"),
30//! }
31//! ```
32//!
33//! # Supported CSS Selectors
34//!
35//! - Type: `div`, `p`, `span`
36//! - Class: `.class`
37//! - ID: `#id`
38//! - Universal: `*`
39//! - Attribute: `[attr]`, `[attr=val]`, `[attr~=val]`, `[attr^=val]`, `[attr$=val]`, `[attr*=val]`
40//! - Pseudo: `:first-child`, `:last-child`, `:nth-child(an+b)`, `:not(sel)`
41//! - Compound: `div.class#id[attr]`
42//! - Combinator: `A B`, `A > B`, `A + B`, `A ~ B`
43//! - Comma list: `div, span`
44//!
45//! # Supported XPath
46//!
47//! - `//tag` — descendant search
48//! - `//tag[@attr='value']` — attribute predicate
49//! - `/path/to/tag` — absolute path
50//! - `//tag[contains(@attr, 'substr')]` — contains predicate
51//! - `//tag[position()=N]` — position predicate
52//! - `//tag/text()` — text extraction
53//! - `..` — parent axis
54
55/// CSS selector AST types.
56pub mod ast;
57/// Bloom filter for ancestor pre-filtering.
58pub mod bloom;
59/// Right-to-left matching engine.
60pub mod matcher;
61/// CSS selector parser.
62pub mod parser;
63/// XPath expression support.
64pub mod xpath;
65
66use std::cell::RefCell;
67use std::collections::{HashMap, VecDeque};
68use std::sync::Arc;
69
70use fhp_core::error::{SelectorError, XPathError};
71use fhp_core::tag::Tag;
72use fhp_tree::node::{NodeFlags, NodeId};
73use fhp_tree::{Document, NodeRef};
74
75use matcher::{select_all_list, select_first_list};
76use parser::parse_selector;
77use xpath::ast::XPathResult;
78
79#[inline]
80fn is_document_element(n: &fhp_tree::node::Node) -> bool {
81    n.depth > 0
82        && !n.flags.has(NodeFlags::IS_TEXT)
83        && !n.flags.has(NodeFlags::IS_COMMENT)
84        && !n.flags.has(NodeFlags::IS_DOCTYPE)
85}
86
87/// A pre-compiled CSS selector for reuse across documents and threads.
88///
89/// Parsing a CSS selector string has non-trivial cost. When the same selector
90/// is used to query many documents (e.g., in a scraping loop), compile it once
91/// and reuse it to eliminate repeated parse overhead.
92///
93/// # Example
94///
95/// ```
96/// use fhp_tree::parse;
97/// use fhp_selector::{CompiledSelector, Selectable};
98///
99/// let sel = CompiledSelector::new("div.content").unwrap();
100/// let doc = parse("<div class=\"content\">Hello</div>").unwrap();
101/// let results = doc.select_compiled(&sel).unwrap();
102/// assert_eq!(results.len(), 1);
103/// ```
104#[derive(Clone)]
105pub struct CompiledSelector {
106    list: Arc<ast::SelectorList>,
107}
108
109impl CompiledSelector {
110    /// Compile a CSS selector string.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`SelectorError::Invalid`] if the selector syntax is invalid.
115    pub fn new(css: &str) -> Result<Self, SelectorError> {
116        Ok(Self {
117            list: Arc::new(parse_selector(css)?),
118        })
119    }
120
121    /// Access the underlying parsed selector list.
122    pub fn as_list(&self) -> &ast::SelectorList {
123        &self.list
124    }
125}
126
127impl core::fmt::Debug for CompiledSelector {
128    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
129        f.debug_struct("CompiledSelector")
130            .field("selectors", &self.list.selectors.len())
131            .finish()
132    }
133}
134
135/// Maximum number of parsed selector ASTs cached per thread.
136const SELECTOR_CACHE_CAPACITY: usize = 256;
137/// Skip caching unusually long selectors to avoid pinning large keys.
138const MAX_CACHED_SELECTOR_LEN: usize = 512;
139
140struct SelectorCache {
141    map: HashMap<String, Arc<ast::SelectorList>>,
142    order: VecDeque<String>,
143}
144
145impl SelectorCache {
146    fn new() -> Self {
147        Self {
148            map: HashMap::with_capacity(SELECTOR_CACHE_CAPACITY),
149            order: VecDeque::with_capacity(SELECTOR_CACHE_CAPACITY),
150        }
151    }
152
153    fn get(&self, css: &str) -> Option<Arc<ast::SelectorList>> {
154        self.map.get(css).map(Arc::clone)
155    }
156
157    fn insert(&mut self, css: &str, list: Arc<ast::SelectorList>) {
158        if self.map.contains_key(css) {
159            self.map.insert(css.to_owned(), list);
160            return;
161        }
162
163        if self.map.len() >= SELECTOR_CACHE_CAPACITY {
164            if let Some(old_key) = self.order.pop_front() {
165                self.map.remove(&old_key);
166            }
167        }
168
169        let key = css.to_owned();
170        self.order.push_back(key.clone());
171        self.map.insert(key, list);
172    }
173}
174
175thread_local! {
176    static SELECTOR_CACHE: RefCell<SelectorCache> = RefCell::new(SelectorCache::new());
177}
178
179#[inline]
180fn parse_selector_cached(css: &str) -> Result<Arc<ast::SelectorList>, SelectorError> {
181    if css.len() > MAX_CACHED_SELECTOR_LEN {
182        return Ok(Arc::new(parse_selector(css)?));
183    }
184
185    SELECTOR_CACHE.with(|cache| {
186        if let Some(list) = cache.borrow().get(css) {
187            return Ok(list);
188        }
189
190        let parsed = Arc::new(parse_selector(css)?);
191        cache.borrow_mut().insert(css, Arc::clone(&parsed));
192        Ok(parsed)
193    })
194}
195
196/// Merge results from multiple roots, deduplicating by NodeId index.
197///
198/// DFS results are already in document order (ascending NodeId index), so
199/// we merge sorted lists instead of using a HashSet. O(n) vs O(n log n).
200fn merge_dedup_results(
201    _arena: &fhp_tree::arena::Arena,
202    roots: &[NodeId],
203    mut query: impl FnMut(NodeId) -> Vec<NodeId>,
204) -> Vec<NodeId> {
205    let mut results = Vec::new();
206    let mut max_seen = u32::MAX; // tracks highest NodeId index seen
207    for &root in roots {
208        for id in query(root) {
209            let idx = id.index() as u32;
210            if results.is_empty() || idx > max_seen {
211                max_seen = idx;
212                results.push(id);
213            } else if idx != max_seen {
214                // Out-of-order (different subtree) — insert if not duplicate.
215                // For typical DFS, this branch is rare.
216                if !results.contains(&id) {
217                    results.push(id);
218                }
219            }
220        }
221    }
222    results
223}
224
225/// A collection of matched nodes from a selector query.
226///
227/// Provides iteration, text extraction, attribute access, and
228/// sub-selection (chaining).
229pub struct Selection<'a> {
230    doc: &'a Document,
231    nodes: Vec<NodeId>,
232}
233
234impl<'a> Selection<'a> {
235    /// Create a new selection from a document and node list.
236    fn new(doc: &'a Document, nodes: Vec<NodeId>) -> Self {
237        Self { doc, nodes }
238    }
239
240    /// Get the first matched node.
241    pub fn first(&self) -> Option<NodeRef<'a>> {
242        self.nodes.first().map(|&id| self.doc.get(id))
243    }
244
245    /// Iterate over matched nodes as [`NodeRef`].
246    pub fn iter(&self) -> impl Iterator<Item = NodeRef<'a>> + '_ {
247        self.nodes.iter().map(|&id| self.doc.get(id))
248    }
249
250    /// Iterate over matched node ids.
251    pub fn node_ids(&self) -> &[NodeId] {
252        &self.nodes
253    }
254
255    /// Collect text content from all matched nodes.
256    pub fn text(&self) -> String {
257        self.iter()
258            .map(|n| n.text_content())
259            .collect::<Vec<_>>()
260            .join("")
261    }
262
263    /// Get an attribute value from the first matched node.
264    pub fn attr(&self, name: &str) -> Option<&'a str> {
265        self.first()?.attr(name)
266    }
267
268    /// Get inner HTML from the first matched node.
269    pub fn inner_html(&self) -> String {
270        self.first().map(|n| n.inner_html()).unwrap_or_default()
271    }
272
273    /// Number of matched nodes.
274    pub fn len(&self) -> usize {
275        self.nodes.len()
276    }
277
278    /// Whether the selection is empty.
279    pub fn is_empty(&self) -> bool {
280        self.nodes.is_empty()
281    }
282
283    /// Sub-select with a pre-compiled selector within the matched nodes.
284    ///
285    /// Each matched node is used as a subtree root, and results are
286    /// deduplicated in document order.
287    pub fn select_compiled(&self, sel: &CompiledSelector) -> Result<Selection<'a>, SelectorError> {
288        let list = &sel.list;
289        if self.nodes.len() == 1 {
290            let results = select_all_list(self.doc.arena(), self.nodes[0], list);
291            return Ok(Selection::new(self.doc, results));
292        }
293        let results = merge_dedup_results(self.doc.arena(), &self.nodes, |root| {
294            select_all_list(self.doc.arena(), root, list)
295        });
296        Ok(Selection::new(self.doc, results))
297    }
298
299    /// Sub-select: run a CSS selector within the matched nodes.
300    ///
301    /// Each matched node is used as a subtree root, and results are
302    /// deduplicated in document order.
303    pub fn select(&self, css: &str) -> Result<Selection<'a>, SelectorError> {
304        let list = parse_selector_cached(css)?;
305        if self.nodes.len() == 1 {
306            // Single root — DFS produces document-order results, no duplicates possible.
307            let results = select_all_list(self.doc.arena(), self.nodes[0], &list);
308            return Ok(Selection::new(self.doc, results));
309        }
310        let results = merge_dedup_results(self.doc.arena(), &self.nodes, |root| {
311            select_all_list(self.doc.arena(), root, &list)
312        });
313        Ok(Selection::new(self.doc, results))
314    }
315
316    /// Evaluate an XPath expression within the matched nodes.
317    ///
318    /// Each matched node is used as a context root, and results are
319    /// deduplicated in document order.
320    ///
321    /// Note: the leading `//` is evaluated **relative to each matched node's
322    /// subtree** (descendant-or-self), not against the whole document. So
323    /// `doc.select("div").xpath("//span")` finds the spans inside each matched
324    /// `div`, and an axis like `//div` also yields the context `div` itself.
325    /// This scoping is intentional so XPath composes with a prior selection;
326    /// for a document-wide query, evaluate from the document root instead.
327    pub fn xpath(&self, expr: &str) -> Result<XPathResult, XPathError> {
328        let parsed = xpath::parser::parse_xpath(expr)?;
329        let mut all_nodes = Vec::new();
330        let mut all_strings = Vec::new();
331        let mut seen = std::collections::HashSet::new();
332
333        for &node_id in &self.nodes {
334            let result = xpath::eval::evaluate(&parsed, self.doc.arena(), node_id);
335            match result {
336                XPathResult::Nodes(nodes) => {
337                    for id in nodes {
338                        if seen.insert(id) {
339                            all_nodes.push(id);
340                        }
341                    }
342                }
343                XPathResult::Strings(strings) => {
344                    all_strings.extend(strings);
345                }
346                XPathResult::Boolean(b) => return Ok(XPathResult::Boolean(b)),
347            }
348        }
349
350        if !all_strings.is_empty() {
351            Ok(XPathResult::Strings(all_strings))
352        } else {
353            Ok(XPathResult::Nodes(all_nodes))
354        }
355    }
356}
357
358impl<'a> IntoIterator for &'a Selection<'a> {
359    type Item = NodeRef<'a>;
360    type IntoIter = SelectionIter<'a>;
361
362    fn into_iter(self) -> Self::IntoIter {
363        SelectionIter {
364            doc: self.doc,
365            inner: self.nodes.iter(),
366        }
367    }
368}
369
370/// Iterator over [`Selection`] results.
371pub struct SelectionIter<'a> {
372    doc: &'a Document,
373    inner: std::slice::Iter<'a, NodeId>,
374}
375
376impl<'a> Iterator for SelectionIter<'a> {
377    type Item = NodeRef<'a>;
378
379    fn next(&mut self) -> Option<Self::Item> {
380        self.inner.next().map(|&id| self.doc.get(id))
381    }
382
383    fn size_hint(&self) -> (usize, Option<usize>) {
384        self.inner.size_hint()
385    }
386}
387
388impl<'a> ExactSizeIterator for SelectionIter<'a> {}
389
390/// Extension trait that adds CSS selector methods to [`Document`].
391///
392/// Import this trait to use `.select()` and convenience methods on a document.
393pub trait Selectable {
394    /// Select all nodes matching a CSS selector.
395    ///
396    /// # Errors
397    ///
398    /// Returns [`SelectorError::Invalid`] if the selector syntax is invalid.
399    ///
400    /// # Example
401    ///
402    /// ```
403    /// use fhp_tree::parse;
404    /// use fhp_selector::Selectable;
405    ///
406    /// let doc = parse("<div><p>Hello</p></div>").unwrap();
407    /// let sel = doc.select("p").unwrap();
408    /// assert_eq!(sel.len(), 1);
409    /// ```
410    fn select(&self, css: &str) -> Result<Selection<'_>, SelectorError>;
411
412    /// Select all nodes matching a pre-compiled CSS selector.
413    ///
414    /// This avoids re-parsing the selector string on every call, which is
415    /// beneficial when the same selector is reused across many documents.
416    fn select_compiled(&self, sel: &CompiledSelector) -> Result<Selection<'_>, SelectorError>;
417
418    /// Select the first node matching a pre-compiled CSS selector.
419    fn select_first_compiled(
420        &self,
421        sel: &CompiledSelector,
422    ) -> Result<Option<NodeRef<'_>>, SelectorError>;
423
424    /// Select the first node matching a CSS selector.
425    fn select_first(&self, css: &str) -> Result<Option<NodeRef<'_>>, SelectorError>;
426
427    /// Find all elements with the given tag.
428    fn find_by_tag(&self, tag: Tag) -> Selection<'_>;
429
430    /// Find an element by its `id` attribute.
431    ///
432    /// Scans all nodes linearly. For repeated lookups, build a
433    /// [`DocumentIndex`] instead.
434    fn find_by_id(&self, id: &str) -> Option<NodeRef<'_>>;
435
436    /// Find all elements with the given CSS class.
437    fn find_by_class(&self, class: &str) -> Selection<'_>;
438
439    /// Find all elements with an attribute matching a value.
440    fn find_by_attr(&self, name: &str, value: &str) -> Selection<'_>;
441
442    /// Evaluate an XPath expression against the document.
443    ///
444    /// # Errors
445    ///
446    /// Returns [`XPathError::Invalid`] if the expression syntax is invalid.
447    ///
448    /// # Example
449    ///
450    /// ```
451    /// use fhp_tree::parse;
452    /// use fhp_selector::Selectable;
453    /// use fhp_selector::xpath::ast::XPathResult;
454    ///
455    /// let doc = parse("<div><p>Hello</p></div>").unwrap();
456    /// let result = doc.xpath("//p").unwrap();
457    /// match result {
458    ///     XPathResult::Nodes(nodes) => assert_eq!(nodes.len(), 1),
459    ///     _ => panic!("expected nodes"),
460    /// }
461    /// ```
462    fn xpath(&self, expr: &str) -> Result<XPathResult, XPathError>;
463}
464
465impl Selectable for Document {
466    fn select(&self, css: &str) -> Result<Selection<'_>, SelectorError> {
467        let list = parse_selector_cached(css)?;
468        let nodes = select_all_list(self.arena(), self.root_id(), &list);
469        Ok(Selection::new(self, nodes))
470    }
471
472    fn select_compiled(&self, sel: &CompiledSelector) -> Result<Selection<'_>, SelectorError> {
473        let nodes = select_all_list(self.arena(), self.root_id(), &sel.list);
474        Ok(Selection::new(self, nodes))
475    }
476
477    fn select_first_compiled(
478        &self,
479        sel: &CompiledSelector,
480    ) -> Result<Option<NodeRef<'_>>, SelectorError> {
481        let node = select_first_list(self.arena(), self.root_id(), &sel.list);
482        Ok(node.map(|id| self.get(id)))
483    }
484
485    fn select_first(&self, css: &str) -> Result<Option<NodeRef<'_>>, SelectorError> {
486        let list = parse_selector_cached(css)?;
487        let node = select_first_list(self.arena(), self.root_id(), &list);
488        Ok(node.map(|id| self.get(id)))
489    }
490
491    fn find_by_tag(&self, tag: Tag) -> Selection<'_> {
492        let arena = self.arena();
493        let mut nodes = Vec::new();
494        for i in 0..arena.len() {
495            let id = NodeId(i as u32);
496            let n = arena.get(id);
497            if n.tag == tag && is_document_element(n) {
498                nodes.push(id);
499            }
500        }
501        Selection::new(self, nodes)
502    }
503
504    fn find_by_id(&self, id: &str) -> Option<NodeRef<'_>> {
505        let arena = self.arena();
506        for i in 0..arena.len() {
507            let node_id = NodeId(i as u32);
508            let attrs = arena.attrs(node_id);
509            for attr in attrs {
510                if arena.attr_name(attr).eq_ignore_ascii_case("id")
511                    && arena.attr_value(attr) == Some(id)
512                {
513                    return Some(self.get(node_id));
514                }
515            }
516        }
517        None
518    }
519
520    fn find_by_class(&self, class: &str) -> Selection<'_> {
521        let arena = self.arena();
522        let mut nodes = Vec::new();
523        for i in 0..arena.len() {
524            let id = NodeId(i as u32);
525            let attrs = arena.attrs(id);
526            for attr in attrs {
527                if arena.attr_name(attr).eq_ignore_ascii_case("class") {
528                    if let Some(val) = arena.attr_value(attr) {
529                        if val.split_whitespace().any(|c| c == class) {
530                            nodes.push(id);
531                            break;
532                        }
533                    }
534                }
535            }
536        }
537        Selection::new(self, nodes)
538    }
539
540    fn find_by_attr(&self, name: &str, value: &str) -> Selection<'_> {
541        let arena = self.arena();
542        let mut nodes = Vec::new();
543        for i in 0..arena.len() {
544            let id = NodeId(i as u32);
545            let attrs = arena.attrs(id);
546            for attr in attrs {
547                if arena.attr_name(attr).eq_ignore_ascii_case(name)
548                    && arena.attr_value(attr) == Some(value)
549                {
550                    nodes.push(id);
551                    break;
552                }
553            }
554        }
555        Selection::new(self, nodes)
556    }
557
558    fn xpath(&self, expr: &str) -> Result<XPathResult, XPathError> {
559        let parsed = xpath::parser::parse_xpath(expr)?;
560        Ok(xpath::eval::evaluate(&parsed, self.arena(), self.root_id()))
561    }
562}
563
564/// Pre-built index for O(1) id, class, and tag lookups.
565///
566/// Build once with a single DFS pass, reuse for many lookups.
567pub struct DocumentIndex<'a> {
568    doc: &'a Document,
569    id_map: HashMap<String, NodeId>,
570    class_map: HashMap<String, Vec<NodeId>>,
571    tag_map: HashMap<Tag, Vec<NodeId>>,
572}
573
574impl<'a> DocumentIndex<'a> {
575    /// Build an index from a document by scanning all nodes in a single pass.
576    ///
577    /// If the arena has a pre-built tag index (constructed inline during tree
578    /// building), the tag map is copied directly — avoiding the tag scan.
579    pub fn build(doc: &'a Document) -> Self {
580        let arena = doc.arena();
581        let mut id_map = HashMap::with_capacity(arena.len() / 8);
582        let mut class_map: HashMap<String, Vec<NodeId>> = HashMap::with_capacity(arena.len() / 4);
583
584        // Use pre-built tag index if available.
585        let tag_map = if let Some(pre_built) = arena.tag_index() {
586            let mut map = HashMap::with_capacity(64);
587            for (tag_u8, ids) in pre_built.iter().enumerate() {
588                let filtered_ids: Vec<_> = ids
589                    .iter()
590                    .copied()
591                    .filter(|&id| is_document_element(arena.get(id)))
592                    .collect();
593                if !filtered_ids.is_empty() {
594                    // SAFETY: Tag is repr(u8), but not all u8 values are valid.
595                    // We only insert entries that were created via new_element with
596                    // a valid Tag, so the cast is safe (values came from Tag as u8).
597                    let tag: Tag = unsafe { std::mem::transmute(tag_u8 as u8) };
598                    map.insert(tag, filtered_ids);
599                }
600            }
601            map
602        } else {
603            let mut map: HashMap<Tag, Vec<NodeId>> = HashMap::with_capacity(64);
604            for i in 0..arena.len() {
605                let node_id = NodeId(i as u32);
606                let n = arena.get(node_id);
607                if !is_document_element(n) {
608                    continue;
609                }
610                map.entry(n.tag).or_default().push(node_id);
611            }
612            map
613        };
614
615        // Build id and class maps — still requires attribute scan.
616        for i in 0..arena.len() {
617            let node_id = NodeId(i as u32);
618            let n = arena.get(node_id);
619
620            // Skip non-element nodes.
621            if !is_document_element(n) {
622                continue;
623            }
624
625            let attrs = arena.attrs(node_id);
626            for attr in attrs {
627                let attr_name = arena.attr_name(attr);
628                if attr_name.eq_ignore_ascii_case("id") {
629                    if let Some(val) = arena.attr_value(attr) {
630                        if let Some(existing) = id_map.get_mut(val) {
631                            *existing = node_id;
632                        } else {
633                            id_map.insert(val.to_owned(), node_id);
634                        }
635                    }
636                }
637                if attr_name.eq_ignore_ascii_case("class") {
638                    if let Some(val) = arena.attr_value(attr) {
639                        for class in val.split_whitespace() {
640                            if let Some(ids) = class_map.get_mut(class) {
641                                ids.push(node_id);
642                            } else {
643                                class_map.insert(class.to_owned(), vec![node_id]);
644                            }
645                        }
646                    }
647                }
648            }
649        }
650
651        Self {
652            doc,
653            id_map,
654            class_map,
655            tag_map,
656        }
657    }
658
659    /// Look up a node by its `id` attribute in O(1).
660    ///
661    /// The `doc` argument is retained for compatibility; lookups resolve against
662    /// the document used to build this index.
663    pub fn find_by_id(&self, _doc: &Document, id: &str) -> Option<NodeRef<'a>> {
664        self.id_map.get(id).map(|&node_id| self.doc.get(node_id))
665    }
666
667    /// Look up all nodes with a given CSS class in O(1).
668    ///
669    /// The `doc` argument is retained for compatibility; lookups resolve against
670    /// the document used to build this index.
671    pub fn find_by_class(&self, _doc: &Document, class: &str) -> Vec<NodeRef<'a>> {
672        self.class_map
673            .get(class)
674            .map(|ids| ids.iter().map(|&id| self.doc.get(id)).collect())
675            .unwrap_or_default()
676    }
677
678    /// Look up all nodes with a given tag in O(1).
679    ///
680    /// The `doc` argument is retained for compatibility; lookups resolve against
681    /// the document used to build this index.
682    pub fn find_by_tag(&self, _doc: &Document, tag: Tag) -> Vec<NodeRef<'a>> {
683        self.tag_map
684            .get(&tag)
685            .map(|ids| ids.iter().map(|&id| self.doc.get(id)).collect())
686            .unwrap_or_default()
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use fhp_tree::parse;
694
695    #[test]
696    fn select_basic() {
697        let doc = parse("<div><p>Hello</p></div>").unwrap();
698        let sel = doc.select("p").unwrap();
699        assert_eq!(sel.len(), 1);
700        assert_eq!(sel.text(), "Hello");
701    }
702
703    #[test]
704    fn select_first_found() {
705        let doc = parse("<div><p>a</p><p>b</p></div>").unwrap();
706        let first = doc.select_first("p").unwrap();
707        assert!(first.is_some());
708        assert_eq!(first.unwrap().text_content(), "a");
709    }
710
711    #[test]
712    fn select_chaining() {
713        let doc = parse("<ul><li><a>1</a></li><li><a>2</a></li></ul>").unwrap();
714        let lis = doc.select("li").unwrap();
715        assert_eq!(lis.len(), 2);
716        let links = lis.select("a").unwrap();
717        assert_eq!(links.len(), 2);
718        assert_eq!(links.text(), "12");
719    }
720
721    #[test]
722    fn find_by_tag_works() {
723        let doc = parse("<div><span>a</span><span>b</span></div>").unwrap();
724        let sel = doc.find_by_tag(Tag::Span);
725        assert_eq!(sel.len(), 2);
726    }
727
728    #[test]
729    fn find_by_id_works() {
730        let doc = parse("<div id=\"main\">x</div><div>y</div>").unwrap();
731        let node = doc.find_by_id("main");
732        assert!(node.is_some());
733        assert_eq!(node.unwrap().text_content(), "x");
734    }
735
736    #[test]
737    fn find_by_id_missing() {
738        let doc = parse("<div>x</div>").unwrap();
739        assert!(doc.find_by_id("nope").is_none());
740    }
741
742    #[test]
743    fn find_by_class_works() {
744        let doc = parse("<div class=\"a b\">x</div><div class=\"c\">y</div>").unwrap();
745        let sel = doc.find_by_class("a");
746        assert_eq!(sel.len(), 1);
747        assert_eq!(sel.text(), "x");
748    }
749
750    #[test]
751    fn find_by_attr_works() {
752        let doc = parse("<a href=\"x\">a</a><a href=\"y\">b</a>").unwrap();
753        let sel = doc.find_by_attr("href", "x");
754        assert_eq!(sel.len(), 1);
755        assert_eq!(sel.text(), "a");
756    }
757
758    #[test]
759    fn selection_attr() {
760        let doc = parse("<a href=\"url\">link</a>").unwrap();
761        let sel = doc.select("a").unwrap();
762        assert_eq!(sel.attr("href"), Some("url"));
763    }
764
765    #[test]
766    fn selection_inner_html() {
767        let doc = parse("<div><p>Hello</p></div>").unwrap();
768        let sel = doc.select("div").unwrap();
769        assert_eq!(sel.inner_html(), "<p>Hello</p>");
770    }
771
772    #[test]
773    fn selection_empty() {
774        let doc = parse("<div>x</div>").unwrap();
775        let sel = doc.select("span").unwrap();
776        assert!(sel.is_empty());
777        assert_eq!(sel.len(), 0);
778        assert!(sel.first().is_none());
779    }
780
781    #[test]
782    fn document_index_o1() {
783        let doc = parse("<div id=\"a\">x</div><div id=\"b\">y</div>").unwrap();
784        let index = DocumentIndex::build(&doc);
785        let node = index.find_by_id(&doc, "b").unwrap();
786        assert_eq!(node.text_content(), "y");
787    }
788
789    #[test]
790    fn document_index_uses_document_it_was_built_from() {
791        let source_doc = parse("<div id=\"a\"><span>source</span></div>").unwrap();
792        let other_doc = parse("<p>other</p>").unwrap();
793        let index = DocumentIndex::build(&source_doc);
794
795        let node = index.find_by_id(&other_doc, "a").unwrap();
796        assert_eq!(node.text_content(), "source");
797    }
798
799    #[test]
800    fn document_index_find_by_class() {
801        let doc = parse("<div class=\"a b\">x</div><span class=\"b c\">y</span><p>z</p>").unwrap();
802        let index = DocumentIndex::build(&doc);
803
804        let class_b = index.find_by_class(&doc, "b");
805        assert_eq!(class_b.len(), 2);
806
807        let class_a = index.find_by_class(&doc, "a");
808        assert_eq!(class_a.len(), 1);
809        assert_eq!(class_a[0].text_content(), "x");
810
811        let class_missing = index.find_by_class(&doc, "nope");
812        assert!(class_missing.is_empty());
813    }
814
815    #[test]
816    fn document_index_find_by_tag() {
817        let doc = parse("<div>a</div><div>b</div><span>c</span>").unwrap();
818        let index = DocumentIndex::build(&doc);
819
820        let divs = index.find_by_tag(&doc, Tag::Div);
821        assert_eq!(divs.len(), 2);
822
823        let spans = index.find_by_tag(&doc, Tag::Span);
824        assert_eq!(spans.len(), 1);
825        assert_eq!(spans[0].text_content(), "c");
826
827        let links = index.find_by_tag(&doc, Tag::A);
828        assert!(links.is_empty());
829    }
830
831    #[test]
832    fn document_index_handles_uppercase_html_attributes() {
833        let doc = parse("<div ID=\"main\" CLASS=\"active hero\">x</div>").unwrap();
834        let index = DocumentIndex::build(&doc);
835
836        assert_eq!(index.find_by_id(&doc, "main").unwrap().text_content(), "x");
837        assert_eq!(index.find_by_class(&doc, "active").len(), 1);
838        assert_eq!(index.find_by_class(&doc, "hero").len(), 1);
839    }
840
841    #[test]
842    fn selection_into_iter() {
843        let doc = parse("<div><p>a</p><p>b</p></div>").unwrap();
844        let sel = doc.select("p").unwrap();
845        let texts: Vec<String> = (&sel).into_iter().map(|n| n.text_content()).collect();
846        assert_eq!(texts, vec!["a", "b"]);
847    }
848
849    #[test]
850    fn xpath_descendant() {
851        let doc = parse("<div><p>Hello</p></div>").unwrap();
852        let result = doc.xpath("//p").unwrap();
853        match result {
854            XPathResult::Nodes(nodes) => assert_eq!(nodes.len(), 1),
855            _ => panic!("expected Nodes"),
856        }
857    }
858
859    #[test]
860    fn xpath_text_extract() {
861        let doc = parse("<div><p>Hello</p></div>").unwrap();
862        let result = doc.xpath("//p/text()").unwrap();
863        match result {
864            XPathResult::Strings(texts) => {
865                assert_eq!(texts.len(), 1);
866                assert_eq!(texts[0], "Hello");
867            }
868            _ => panic!("expected Strings"),
869        }
870    }
871
872    #[test]
873    fn xpath_invalid() {
874        let doc = parse("<div>x</div>").unwrap();
875        assert!(doc.xpath("").is_err());
876        assert!(doc.xpath("bad").is_err());
877    }
878
879    #[test]
880    fn selection_xpath_chaining() {
881        let doc = parse("<ul><li>1</li><li>2</li></ul><ol><li>3</li></ol>").unwrap();
882        let sel = doc.select("ul").unwrap();
883        let result = sel.xpath("//li").unwrap();
884        match result {
885            XPathResult::Nodes(nodes) => assert_eq!(nodes.len(), 2),
886            _ => panic!("expected Nodes"),
887        }
888    }
889
890    #[test]
891    fn compiled_selector_basic() {
892        let sel = CompiledSelector::new("p").unwrap();
893        let doc = parse("<div><p>Hello</p></div>").unwrap();
894        let results = doc.select_compiled(&sel).unwrap();
895        assert_eq!(results.len(), 1);
896        assert_eq!(results.text(), "Hello");
897    }
898
899    #[test]
900    fn compiled_selector_first() {
901        let sel = CompiledSelector::new("p").unwrap();
902        let doc = parse("<div><p>a</p><p>b</p></div>").unwrap();
903        let first = doc.select_first_compiled(&sel).unwrap();
904        assert!(first.is_some());
905        assert_eq!(first.unwrap().text_content(), "a");
906    }
907
908    #[test]
909    fn compiled_selector_reuse_across_docs() {
910        let sel = CompiledSelector::new("span.active").unwrap();
911        let doc1 = parse("<span class=\"active\">one</span>").unwrap();
912        let doc2 = parse("<div><span class=\"active\">two</span></div>").unwrap();
913        assert_eq!(doc1.select_compiled(&sel).unwrap().text(), "one");
914        assert_eq!(doc2.select_compiled(&sel).unwrap().text(), "two");
915    }
916
917    #[test]
918    fn compiled_selector_chaining() {
919        let sel = CompiledSelector::new("a").unwrap();
920        let doc = parse("<ul><li><a>1</a></li><li><a>2</a></li></ul>").unwrap();
921        let lis = doc.select("li").unwrap();
922        let links = lis.select_compiled(&sel).unwrap();
923        assert_eq!(links.len(), 2);
924    }
925
926    #[test]
927    fn compiled_selector_invalid() {
928        assert!(CompiledSelector::new("").is_err());
929    }
930
931    #[test]
932    fn compiled_selector_clone() {
933        let sel = CompiledSelector::new("div").unwrap();
934        let sel2 = sel.clone();
935        let doc = parse("<div>ok</div>").unwrap();
936        assert_eq!(doc.select_compiled(&sel2).unwrap().len(), 1);
937    }
938}