Skip to main content

scah_query_ir/query/compiler/
builder.rs

1use super::SelectorParseError;
2use super::query::{Query, QuerySection, QuerySectionId, TransitionId};
3use super::transition::Transition;
4
5/// Controls which pieces of content to capture for matched elements.
6///
7/// When an element matches a CSS selector, scah can optionally capture its
8/// **inner HTML** (the raw markup between the opening and closing tags) and/or
9/// its **text content** (the concatenated, whitespace-trimmed text nodes).
10///
11/// Use the convenience constructors [`Save::all`], [`Save::none`],
12/// [`Save::only_inner_html`], and [`Save::only_text_content`] to create
13/// common configurations.
14///
15/// # Example
16///
17/// ```rust
18/// use scah_query_ir::Save;
19///
20/// // Capture everything
21/// let save = Save::all();
22/// assert!(save.inner_html);
23/// assert!(save.text_content);
24///
25/// // Capture only text content (lighter weight)
26/// let save = Save::only_text_content();
27/// assert!(!save.inner_html);
28/// assert!(save.text_content);
29/// ```
30#[derive(PartialEq, Debug, Default, Clone, Copy)]
31pub struct Save {
32    /// When `true`, the raw HTML between the element's opening and closing
33    /// tags is stored as [`Element::inner_html`](crate::Element::inner_html).
34    pub inner_html: bool,
35    /// When `true`, the concatenated text content of the element is stored
36    /// and retrievable via [`Element::text_content()`](crate::Element::text_content).
37    pub text_content: bool,
38}
39
40impl Save {
41    /// Capture only the raw inner HTML of matched elements.
42    pub fn only_inner_html() -> Self {
43        Self {
44            inner_html: true,
45            text_content: false,
46        }
47    }
48
49    /// Capture only the text content of matched elements.
50    pub fn only_text_content() -> Self {
51        Self {
52            inner_html: false,
53            text_content: true,
54        }
55    }
56
57    /// Capture both inner HTML and text content.
58    pub fn all() -> Self {
59        Self {
60            inner_html: true,
61            text_content: true,
62        }
63    }
64
65    /// Capture neither inner HTML nor text content.
66    ///
67    /// The matched element's tag name, id, class, and attributes are still
68    /// stored; only the heavier content extraction is skipped.
69    pub fn none() -> Self {
70        Self {
71            inner_html: false,
72            text_content: false,
73        }
74    }
75}
76
77/// Whether a query section should match **all** occurrences or only the
78/// **first** one.
79///
80/// Using [`SelectionKind::First`] enables an early-exit optimisation:
81/// once the first match is found (and its content captured), the parser
82/// can skip the remaining document for that query branch.
83#[derive(PartialEq, Debug, Clone, Copy)]
84pub enum SelectionKind {
85    /// Match every occurrence of the selector.
86    All,
87    /// Match only the first occurrence, enabling early exit.
88    First,
89}
90
91/// An in-progress query being assembled via a builder pattern.
92///
93/// You typically don't construct a `QueryBuilder` directly; instead,
94/// start with [`Query::all`](crate::Query::all) or
95/// [`Query::first`](crate::Query::first) and then chain further
96/// selectors with [`.all()`](QueryBuilder::all),
97/// [`.first()`](QueryBuilder::first), and [`.then()`](QueryBuilder::then).
98/// Finalise with [`.build()`](QueryBuilder::build) to produce a [`Query`].
99///
100/// # Example
101///
102/// ```rust
103/// use scah_query_ir::{Query, Save};
104///
105/// let query = Query::all("main > section", Save::all())?
106///     .then(|ctx| Ok([
107///         ctx.all("> a[href]", Save::all())?,
108///         ctx.all("div a", Save::only_text_content())?,
109///     ]))?
110///     .build();
111/// # Ok::<(), scah_query_ir::SelectorParseError>(())
112/// ```
113#[derive(Debug, Clone)]
114pub struct QueryBuilder<'query> {
115    /// Internal automaton transitions (compiled selector segments).
116    pub states: Vec<Transition<'query>>,
117    /// Internal ordered list of query sections.
118    pub selection: Vec<QuerySection<'query>>,
119}
120
121impl<'query> QueryBuilder<'query> {
122    pub fn all(mut self, query: &'query str, save: Save) -> Result<Self, SelectorParseError> {
123        assert!(!self.selection.is_empty());
124
125        let current_state_len = self.states.len();
126        let mut states = Transition::generate_transitions_from_string(query)?;
127
128        let parent_index = QuerySectionId(self.selection.len() - 1);
129        let range = TransitionId(current_state_len)..TransitionId(current_state_len + states.len());
130        self.selection.push(QuerySection::new(
131            query,
132            save,
133            SelectionKind::All,
134            range,
135            Some(parent_index),
136        ));
137
138        self.states.append(&mut states);
139
140        Ok(self)
141    }
142
143    /// Append a child selector that matches **all** occurrences.
144    ///
145    /// The new selector is scoped to elements that already matched
146    /// the previous selector in the chain.
147    ///
148    pub fn first(mut self, query: &'query str, save: Save) -> Result<Self, SelectorParseError> {
149        assert!(!self.selection.is_empty());
150
151        let current_state_len = self.states.len();
152        let mut states = Transition::generate_transitions_from_string(query)?;
153
154        let parent_index = QuerySectionId(self.selection.len() - 1);
155        let range = TransitionId(current_state_len)..TransitionId(current_state_len + states.len());
156        self.selection.push(QuerySection::new(
157            query,
158            save,
159            SelectionKind::First,
160            range,
161            Some(parent_index),
162        ));
163
164        self.states.append(&mut states);
165
166        Ok(self)
167    }
168
169    /// Append a child selector that matches only the **first** occurrence.
170    ///
171    /// Enables early-exit optimisation for this branch of the query tree.
172    ///
173    pub fn append(&mut self, parent: QuerySectionId, mut other: Self) {
174        let state_length = self.states.len();
175        let selection_length = self.selection.len();
176
177        let mut last_sibling: Option<QuerySectionId> = {
178            if parent.index() + 1 == self.selection.len() {
179                None
180            } else {
181                let mut sibling_index = QuerySectionId(parent.index() + 1);
182                while self.selection[sibling_index.index()].next_sibling.is_some() {
183                    sibling_index = self.selection[sibling_index.index()].next_sibling.unwrap();
184                }
185
186                Some(sibling_index)
187            }
188        };
189        for index in 0..other.selection.len() {
190            let query = &mut other.selection[index];
191            query.range.start = TransitionId(query.range.start.index() + state_length);
192            query.range.end = TransitionId(query.range.end.index() + state_length);
193            if let Some(next_sibling) = query.next_sibling {
194                query.next_sibling = Some(QuerySectionId(next_sibling.index() + selection_length));
195            }
196
197            if let Some(idx) = query.parent {
198                query.parent = Some(QuerySectionId(idx.index() + selection_length));
199            } else {
200                query.parent = Some(parent);
201
202                let current_index = QuerySectionId(selection_length + index);
203                last_sibling = match last_sibling {
204                    Some(sibling) => {
205                        if sibling.index() < selection_length {
206                            self.selection[sibling.index()].next_sibling = Some(current_index);
207                        } else {
208                            other.selection[sibling.index() - selection_length].next_sibling =
209                                Some(current_index);
210                        }
211                        Some(current_index)
212                    }
213                    None => Some(current_index),
214                };
215            }
216        }
217        self.states.append(&mut other.states);
218        self.selection.append(&mut other.selection);
219    }
220
221    /// Branch into multiple child queries using a closure.
222    ///
223    /// The closure receives a [`QueryFactory`] that can create new
224    /// sub-queries. Each sub-query is scoped to run only within elements
225    /// matched by the current (most recently added) selector.
226    ///
227    /// This is the key mechanism for **structured querying**; extracting
228    /// hierarchical data relationships in a single streaming pass.
229    ///
230    /// # Example
231    ///
232    /// ```rust
233    /// use scah_query_ir::{Query, Save};
234    ///
235    /// let query = Query::all("article", Save::none())?
236    ///     .then(|article| Ok([
237    ///         article.first("h1", Save::only_text_content())?,
238    ///         article.all("a[href]", Save::all())?,
239    ///     ]))?
240    ///     .build();
241    /// # Ok::<(), scah_query_ir::SelectorParseError>(())
242    /// ```
243    pub fn then<F, I>(mut self, func: F) -> Result<Self, SelectorParseError>
244    where
245        F: FnOnce(QueryFactory) -> Result<I, SelectorParseError>,
246        I: IntoIterator<Item = Self>,
247    {
248        let factory = QueryFactory {};
249        let children = func(factory)?;
250
251        let current_index = QuerySectionId(self.selection.len() - 1);
252        for child in children {
253            self.append(current_index, child);
254        }
255        Ok(self)
256    }
257
258    fn exit_at_section(&self) -> Option<QuerySectionId> {
259        // returns the position in the selection tree where it can early exit
260        // TODO: I should add a required flag for QuerySections, so that the first selection is nulled
261        //  -> Basicly you can't return the first section without a perticular section behind added
262        //  -> If you come back to the section without saving the required section,
263        //      then you delete the saved data and you start over.
264
265        fn search_for_single_exit_section(
266            index: QuerySectionId,
267            list: &[QuerySection<'_>],
268        ) -> Option<QuerySectionId> {
269            // If you have a section with MULTIPLE children that can early exit,
270            //   then this parent node will become the exit section
271            if index.index() >= list.len() {
272                return None;
273            }
274            let section = &list[index.index()];
275            let stop_here = match &section.kind {
276                //BUG: you can only early exit when the ALL of them have been found, thus the parent must be awaited for
277                SelectionKind::All => return None,
278
279                // This is it need's to find the </{element}> to get either inner_html or text_content
280                SelectionKind::First => section.save != Save::none(),
281            };
282            if stop_here {
283                return Some(index);
284            }
285
286            let mut child = QuerySectionId(index.index() + 1);
287            if child.index() >= list.len() {
288                return Some(index);
289            }
290
291            let mut child_response: Option<QuerySectionId> = None;
292            if let Some(parent) = list[child.index()].parent
293                && parent == index
294            {
295                loop {
296                    child_response = match child_response {
297                        None => search_for_single_exit_section(child, list),
298                        Some(_) => {
299                            // If their's more than one child that can early exit then
300                            // the parent is chosen
301                            return Some(index);
302                        }
303                    };
304
305                    if let Some(sibling) = list[child.index()].next_sibling {
306                        child = sibling;
307                    } else {
308                        break;
309                    }
310                }
311            }
312
313            if child_response.is_some() {
314                return child_response;
315            }
316            Some(index)
317        }
318
319        search_for_single_exit_section(QuerySectionId(0), &self.selection)
320    }
321}
322
323impl<'query> QueryBuilder<'query> {
324    /// Finalise the builder and produce a compiled [`Query`].
325    ///
326    /// This computes early-exit optimisation metadata and converts the
327    /// internal vectors into boxed slices. After calling `build`, pass
328    /// the resulting `Query` to [`parse`](crate::parse).
329    pub fn build(self) -> Query<'query> {
330        let exit_at_section_end = self.exit_at_section();
331        let states_box = self.states.into_boxed_slice();
332        let query_box = self.selection.into_boxed_slice();
333        Query {
334            states: states_box,
335            queries: query_box,
336            exit_at_section_end,
337        }
338    }
339}
340
341/// A factory for creating child [`QueryBuilder`]s inside a
342/// [`QueryBuilder::then`] closure.
343///
344/// You never construct this directly; it is provided as the argument to
345/// the closure passed to `.then()`.
346pub struct QueryFactory {}
347impl<'query> QueryFactory {
348    /// Create a child query that matches **all** occurrences of the selector.
349    pub fn all(
350        &self,
351        query: &'query str,
352        save: Save,
353    ) -> Result<QueryBuilder<'query>, SelectorParseError> {
354        Query::all(query, save)
355    }
356
357    /// Create a child query that matches only the **first** occurrence.
358    pub fn first(
359        &self,
360        query: &'query str,
361        save: Save,
362    ) -> Result<QueryBuilder<'query>, SelectorParseError> {
363        Query::first(query, save)
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use crate::{ClassSelections, Query, QuerySectionId, Save, SelectionKind};
370
371    #[test]
372    fn test_builder_with_class_chaining() {
373        let query = Query::all("a.blue.exit", Save::all()).unwrap().build();
374        assert_eq!(
375            query.states[0].predicate.classes,
376            ClassSelections::from_static(&["blue", "exit"])
377        );
378    }
379
380    #[test]
381    fn test_early_exit() {
382        let query = Query::all("a", Save::all()).unwrap();
383        assert_eq!(query.exit_at_section(), None);
384
385        let query = Query::all("a", Save::none()).unwrap();
386        assert_eq!(query.exit_at_section(), None);
387
388        let query = Query::first("a", Save::all()).unwrap();
389        assert_eq!(query.exit_at_section(), Some(QuerySectionId(0)));
390
391        let query = Query::first("a", Save::none()).unwrap();
392        assert_eq!(query.exit_at_section(), Some(QuerySectionId(0)));
393
394        let query = Query::all("p", Save::all())
395            .unwrap()
396            .first("a", Save::all())
397            .unwrap();
398        assert_eq!(query.exit_at_section(), None);
399
400        let query = Query::first("p", Save::all())
401            .unwrap()
402            .all("a", Save::all())
403            .unwrap();
404        assert_eq!(query.exit_at_section(), Some(QuerySectionId(0)));
405
406        let query = Query::first("p", Save::all())
407            .unwrap()
408            .first("a", Save::all())
409            .unwrap();
410        assert_eq!(query.exit_at_section(), Some(QuerySectionId(0)));
411
412        let query = Query::first("p", Save::none())
413            .unwrap()
414            .first("a", Save::none())
415            .unwrap();
416        assert_eq!(query.exit_at_section(), Some(QuerySectionId(1)));
417    }
418
419    #[test]
420    fn test_invalid_selectors_fail_to_build() {
421        let invalid = [
422            "",
423            "a > ",
424            ".",
425            "#",
426            " a ~ b",
427            "a + b",
428            "a[]",
429            "*",
430            "a[123=\"321\"]",
431        ];
432
433        for selector in invalid {
434            assert!(Query::all(selector, Save::none()).is_err(), "{selector}");
435        }
436    }
437
438    #[test]
439    fn test_then_with_all_and_first() {
440        let query = Query::all("article", Save::none())
441            .unwrap()
442            .then(|article| {
443                Ok([
444                    article.all("a[href]", Save::all())?,
445                    article.first("h1", Save::only_text_content())?,
446                ])
447            })
448            .unwrap()
449            .build();
450
451        assert_eq!(query.queries.len(), 3);
452        assert_eq!(query.queries[1].source, "a[href]");
453        assert_eq!(query.queries[2].source, "h1");
454    }
455
456    #[test]
457    fn test_then_propagates_invalid_selector_from_callback() {
458        let error = Query::all("article", Save::none())
459            .unwrap()
460            .then(|article| {
461                Ok([
462                    article.all("a[href]", Save::all())?,
463                    article.first("a + b", Save::only_text_content())?,
464                ])
465            })
466            .unwrap_err();
467
468        assert_eq!(error.message(), "unsupported combinator '+'");
469    }
470
471    #[test]
472    fn test_then_builds_sibling_links_in_callback_order() {
473        let query = Query::all("article", Save::none())
474            .unwrap()
475            .then(|article| {
476                Ok([
477                    article.all("a[href]", Save::all())?,
478                    article.first("h1", Save::only_text_content())?,
479                    article.all("p", Save::none())?,
480                ])
481            })
482            .unwrap()
483            .build();
484
485        assert_eq!(query.queries.len(), 4);
486        assert_eq!(query.queries[1].parent, Some(QuerySectionId(0)));
487        assert_eq!(query.queries[2].parent, Some(QuerySectionId(0)));
488        assert_eq!(query.queries[3].parent, Some(QuerySectionId(0)));
489        assert_eq!(query.queries[1].next_sibling, Some(QuerySectionId(2)));
490        assert_eq!(query.queries[2].next_sibling, Some(QuerySectionId(3)));
491        assert_eq!(query.queries[3].next_sibling, None);
492        assert_eq!(query.queries[1].kind, SelectionKind::All);
493        assert_eq!(query.queries[2].kind, SelectionKind::First);
494        assert_eq!(query.queries[3].kind, SelectionKind::All);
495    }
496
497    #[test]
498    fn test_nested_then_supports_all_and_first() {
499        let query = Query::all("article", Save::none())
500            .unwrap()
501            .then(|article| {
502                Ok([article.all("section", Save::none())?.then(|section| {
503                    Ok([
504                        section.first("h2", Save::only_text_content())?,
505                        section.all("a[href]", Save::all())?,
506                    ])
507                })?])
508            })
509            .unwrap()
510            .build();
511
512        assert_eq!(query.queries.len(), 4);
513        assert_eq!(query.queries[1].source, "section");
514        assert_eq!(query.queries[2].source, "h2");
515        assert_eq!(query.queries[3].source, "a[href]");
516        assert_eq!(query.queries[2].parent, Some(QuerySectionId(1)));
517        assert_eq!(query.queries[3].parent, Some(QuerySectionId(1)));
518        assert_eq!(query.queries[2].next_sibling, Some(QuerySectionId(3)));
519    }
520
521    #[test]
522    fn test_then_returns_error_without_partial_append() {
523        let builder = Query::all("article", Save::none())
524            .unwrap()
525            .then(|article| {
526                let first = article.all("section", Save::none())?;
527                let second = article.first("a + b", Save::all());
528                match second {
529                    Ok(second) => Ok([first, second]),
530                    Err(err) => Err(err),
531                }
532            });
533
534        assert!(builder.is_err());
535        let error = builder.unwrap_err();
536        assert_eq!(error.message(), "unsupported combinator '+'");
537    }
538}