pub mod debug;
mod engine;
mod html;
mod store;
mod support;
#[cfg(all(any(debug_assertions, test), feature = "otel"))]
mod otel;
pub use engine::multiplexer::QueryMultiplexer;
pub use html::element::builder::XHtmlElement;
pub use html::parser::XHtmlParser;
pub use scah_macros::query;
pub use scah_query_ir::lazy;
pub use scah_query_ir::{
Attribute, AttributeSelection, AttributeSelectionKind, AttributeSelections, ClassSelections,
Combinator, ElementPredicate, IElement, Position, Query, QueryBuilder, QueryFactory,
QuerySection, QuerySectionId, QuerySpec, Save, SelectionKind, SelectorParseError, StaticQuery,
Transition, TransitionId,
};
pub use scah_reader::Reader;
pub use store::{CapacityOptions, Element, ElementId, Store};
#[cfg(feature = "bench-internals")]
#[doc(hidden)]
pub mod bench_internals {
pub use crate::html::tag::{ScopeKind, TagFlags};
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
EmptyQueries,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::EmptyQueries => write!(f, "parse requires at least one query"),
}
}
}
impl std::error::Error for ParseError {}
pub fn parse<'a: 'query, 'html: 'query, 'query: 'html, Q>(
html: &'html str,
queries: &'a [Q],
) -> Result<Store<'html, 'query>, ParseError>
where
Q: QuerySpec<'query>,
{
if queries.is_empty() {
return Err(ParseError::EmptyQueries);
}
let no_extra_allocations = queries.iter().all(|q| q.exit_at_section_end().is_some());
let selectors = QueryMultiplexer::new(queries);
let mut parser = if no_extra_allocations {
XHtmlParser::new(selectors)
} else {
XHtmlParser::with_capacity(selectors, html.len())
};
let mut reader = Reader::new(html);
parser.trace_parse_started(html.len(), queries.len());
while parser.next(&mut reader) {}
Ok(parser.finish())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_query_slice_returns_error() {
let html = "<main><a href='x'>x</a></main>";
let queries: &[Query] = &[];
let result = parse(html, queries);
assert!(matches!(result, Err(ParseError::EmptyQueries)));
}
#[test]
fn non_empty_query_slice_succeeds() {
let html = "<main><a href='x'>x</a></main>";
let queries = &[Query::all("a", Save::all())
.expect("valid selector")
.build()];
let store = parse(html, queries).expect("parse succeeds");
assert_eq!(store.get("a").unwrap().count(), 1);
}
#[test]
fn parse_first_query_skips_full_document_preallocation() {
let filler = "<span class=\"filler\"></span>".repeat(10_000);
let html_len = filler.len();
let html = format!("<div id=\"hit\"></div>{}", filler);
let query = Query::first("#hit", Save::none()).unwrap().build();
let queries = &[query];
let store = parse(&html, queries).unwrap();
let capacity_path_reservation = html_len / 48;
assert!(
store.elements.capacity() < capacity_path_reservation,
"early-exit parse capacity ({}) must be far below full-document reservation ({})",
store.elements.capacity(),
capacity_path_reservation,
);
assert!(
store.attributes.capacity() < html_len / 24,
"early-exit parse must not preallocate attribute arena"
);
let hits: Vec<_> = store.get("#hit").unwrap().collect();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].name, "div");
}
#[test]
fn parse_all_query_uses_capacity_preallocation_path() {
let html = format!(
"<div id=\"hit\"></div>{}",
"<span class=\"filler\"></span>".repeat(5_000)
);
let query = Query::all("#hit", Save::none()).unwrap().build();
let queries = &[query];
let store = parse(&html, queries).unwrap();
assert!(
store.elements.capacity() > 0,
"non-early-exit parse must preallocate element arena"
);
assert_eq!(
store.text_content.content.capacity(),
0,
"capacity path with Save::none must skip text buffer preallocation"
);
}
#[test]
fn parse_with_save_text_content_reserves_text_buffer() {
let html = "<div>text content here</div>".repeat(5_000);
let query = Query::all("div", Save::only_text_content())
.unwrap()
.build();
let queries = &[query];
let store = parse(&html, queries).unwrap();
assert!(
store.text_content.content.capacity() > 0,
"text buffer must be preallocated when queries need text content"
);
}
#[test]
fn parse_first_early_exit_still_captures_text_when_needed() {
let html = "<div id=\"hit\">important text</div>".to_string()
+ &"<span>filler</span>".repeat(1_000);
let query = Query::first("#hit", Save::only_text_content())
.unwrap()
.build();
let queries = &[query];
let store = parse(&html, queries).unwrap();
let hits: Vec<_> = store.get("#hit").unwrap().collect();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].text_content(&store), Some("important text"));
}
#[test]
fn element_attribute_lookup_is_case_insensitive_for_html_attributes() {
let html = "<a HREF='x'></a>";
let queries = &[Query::all("a", Save::all())
.expect("valid selector")
.build()];
let store = parse(html, queries).expect("parse succeeds");
let a = store.get("a").unwrap().next().unwrap();
assert_eq!(a.attribute(&store, "href"), Some("x"));
assert_eq!(a.attribute(&store, "HREF"), Some("x"));
assert_eq!(a.attribute(&store, "Href"), Some("x"));
}
}