// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// A selection: the nodes a query returned, and the document they belong to.
/// The empty result is an empty list, never a special value, so a chain never
/// branches — and every query method exists on `Selection`, on `Element` and
/// on `Document`, so a chain never has to stop at a node.
///
/// doc.xpath("//ul").css("li.external").xpath("@href")
/// .selector("tag", "a").custom(DeepLinks::new())
/// .items[2].children[0].css("span").text()
pub struct Selection {
priv doc : Document?
items : Array[Node]
}
///|
/// An empty selection, belonging to no document.
pub fn Selection::new() -> Selection {
{ doc: None, items: [], }
}
///|
/// A selection of these nodes, in the order given, carrying the document the
/// first of them belongs to.
pub fn Selection::of(nodes : Array[Node]) -> Selection {
let mut doc : Document? = None
for node in nodes {
if node is ElementNode(data) {
match data.document() {
Some(found) => {
doc = Some(found)
break
}
None => ()
}
}
}
{ doc, items: nodes, }
}
///|
/// Wrap one node, answering a selection that carries the document the node
/// belongs to when it has one.
fn selection_of(node : Node) -> Selection {
match node {
ElementNode(data) => { doc: data.document(), items: [node], }
_ => { doc: None, items: [node], }
}
}
///|
/// A selection of these nodes, belonging to this document.
fn Document::selection(self : Document, nodes : Array[Node]) -> Selection {
{ doc: Some(self), items: nodes, }
}
///|
/// The nodes a query found, deduplicated by identity: overlapping contexts
/// must not answer the same node twice. The scan is skipped when there is one
/// context, whose walk cannot repeat a node.
fn collect(nodes : Array[Node], doc : Document?, unique~ : Bool) -> Selection {
if !unique {
return { doc, items: nodes, }
}
let kept : Array[Node] = []
for node in nodes {
let mut seen = false
for have in kept {
if node is ElementNode(a) &&
have is ElementNode(b) &&
physical_equal(a, b) {
seen = true
break
}
}
if !seen {
kept.push(node)
}
}
{ doc, items: kept, }
}
///|
/// The engine every descendant search shares: walk each context's subtree,
/// keep what `keep` accepts, answer in document order.
fn gather(ctx : Selection, keep : (Element) -> Bool) -> Selection {
if ctx.items.is_empty() {
return ctx
}
let out : Array[Node] = []
for node in ctx.items {
if node is ElementNode(data) {
data.walk(fn(found) {
if found is ElementNode(element) && keep(element) {
out.push(found)
}
})
}
}
collect(out, ctx.doc, unique=ctx.items.length() > 1)
}
// Query. Each narrows the selection and answers a new one; the empty result
// is an empty selection.
///|
/// Narrow with a CSS selector, Selectors Level 4's statically decidable
/// subset. Searches the descendants of the selected nodes — the context
/// itself is never a candidate, `querySelectorAll`'s rule.
pub fn Selection::css(
self : Selection,
query : String,
) -> Selection raise Refused {
let complexes = css_parse(query)
gather(self, fn(element) { complex_list_matches(complexes, element) })
}
///|
/// The elements among the descendants whose `id` attribute is `id`.
pub fn Selection::by_id(self : Selection, id : String) -> Selection {
gather(self, fn(element) { element.attr("id") == Some(id) })
}
///|
/// The elements among the descendants that carry the class `name`.
pub fn Selection::by_class(self : Selection, name : String) -> Selection {
gather(self, fn(element) { element.has_class(name) })
}
///|
/// The elements among the descendants whose name is `name`; `*` answers them
/// all.
pub fn Selection::by_tag(self : Selection, name : String) -> Selection {
gather(self, fn(element) { element.name == name || name == "*" })
}
///|
/// The elements among the descendants whose `name` attribute is `name`.
pub fn Selection::by_name(self : Selection, name : String) -> Selection {
gather(self, fn(element) { element.attr("name") == Some(name) })
}
///|
/// Keep the selected nodes a closure accepts.
pub fn Selection::filter(self : Selection, keep : (Node) -> Bool) -> Selection {
collect(self.items.filter(keep), self.doc, unique=false)
}
///|
/// The nearest ancestor — self included — matching a CSS selector.
pub fn Selection::closest(
self : Selection,
query : String,
) -> Selection raise Refused {
let complexes = css_parse(query)
let out : Array[Node] = []
for node in self.items {
if node is ElementNode(start) {
let mut current : Element? = Some(start)
let mut climbing = true
while climbing {
match current {
Some(element) =>
if complex_list_matches(complexes, element) {
out.push(ElementNode(element))
climbing = false
} else {
current = element.parent
}
None => climbing = false
}
}
}
}
collect(out, self.doc, unique=self.items.length() > 1)
}
///|
/// Run a selector by the name it was registered under, built-in or the
/// caller's own. An unregistered name raises.
pub fn Selection::selector(
self : Selection,
name : String,
query : String,
) -> Selection raise Refused {
match self.doc {
Some(doc) =>
match doc.selectors.get(name) {
Some(sel) => sel.select(self, query)
None => raise UnknownSelector(name~)
}
None => raise UnknownSelector(name~)
}
}
///|
/// Run a selector that was never registered: no name, no state.
pub fn Selection::custom(
self : Selection,
sel : &Selector,
) -> Selection raise Refused {
sel.select(self, "")
}
// Narrowing.
///|
/// `selection[i]` in the chain: the node at that position, so a chain can step
/// down into one node and carry on. Out of range answers a detached empty
/// element rather than panicking.
#alias("_[_]")
pub fn Selection::at(self : Selection, index : Int32) -> Node {
if index >= 0 && index < self.items.length() {
self.items[index]
} else {
ElementNode(Element::new(""))
}
}
///|
/// The first selected node; empty stays empty.
pub fn Selection::first(self : Selection) -> Selection {
if self.items.is_empty() {
self
} else {
{ doc: self.doc, items: [self.items[0]], }
}
}
///|
/// The last selected node; empty stays empty.
pub fn Selection::last(self : Selection) -> Selection {
let n = self.items.length()
if n == 0 {
self
} else {
{ doc: self.doc, items: [self.items[n - 1]], }
}
}
///|
/// The selected node at `i`; a negative index counts from the end, the way
/// jQuery's `eq` does. Out of range answers empty.
pub fn Selection::eq(self : Selection, i : Int32) -> Selection {
let n = self.items.length()
let at = if i < 0 { n + i } else { i }
if at < 0 || at >= n {
{ doc: self.doc, items: [], }
} else {
{ doc: self.doc, items: [self.items[at]], }
}
}
///|
/// The children of every selected node.
pub fn Selection::children(self : Selection) -> Selection {
let out : Array[Node] = []
for node in self.items {
if node is ElementNode(data) {
for child in data.children {
out.push(child)
}
}
}
collect(out, self.doc, unique=self.items.length() > 1)
}
///|
/// The parent of every selected node that has one.
pub fn Selection::parent(self : Selection) -> Selection {
let out : Array[Node] = []
for node in self.items {
if node is ElementNode(data) {
match data.parent {
Some(above) => out.push(ElementNode(above))
None => ()
}
}
}
collect(out, self.doc, unique=self.items.length() > 1)
}
///|
/// Every node below the selected nodes, in document order.
pub fn Selection::descendants(self : Selection) -> Selection {
let out : Array[Node] = []
for node in self.items {
if node is ElementNode(data) {
data.walk(fn(found) { out.push(found) })
}
}
collect(out, self.doc, unique=self.items.length() > 1)
}
// Reading.
///|
/// How many nodes the selection holds.
pub fn Selection::length(self : Selection) -> Int32 {
self.items.length()
}
///|
/// The text content of every selected node, concatenated.
pub fn Selection::text(self : Selection) -> String {
let out = StringBuilder()
for node in self.items {
out.write_string(node.text())
}
out.to_string()
}
///|
/// The inner HTML of the first selected element, written in the flavour its
/// document was read as; `""` when the selection holds no element.
pub fn Selection::html(self : Selection) -> String {
for node in self.items {
if node is ElementNode(data) {
return inner_html(data)
}
}
""
}
///|
/// The element's inner markup, written in the flavour its document was read as.
pub fn Element::html(self : Element) -> String {
inner_html(self)
}
///|
fn inner_html(element : Element) -> String {
let out = StringBuilder()
let html = match element.document() {
Some(doc) => doc.flavour is Html
None => false
}
for child in element.children {
if html {
write_html(child, out, raw=false)
} else {
write_xml(child, out)
}
}
out.to_string()
}
///|
/// The named attribute of the first selected element that carries it.
pub fn Selection::attr(self : Selection, name : String) -> String? {
for node in self.items {
if node is ElementNode(data) {
match data.attr(name) {
Some(value) => return Some(value)
None => ()
}
}
}
None
}
// Editing. Each verb applies to every selected element.
///|
/// Append a node to every selected element.
pub fn Selection::append(self : Selection, node : Node) -> Unit {
for item in self.items {
if item is ElementNode(data) {
data.append(node)
}
}
}
///|
/// Insert a node as the first child of every selected element.
pub fn Selection::prepend(self : Selection, node : Node) -> Unit {
for item in self.items {
if item is ElementNode(data) {
data.prepend(node)
}
}
}
///|
/// Detach every selected element from its parent.
pub fn Selection::remove(self : Selection) -> Unit {
for item in self.items {
if item is ElementNode(data) {
ignore(data.remove())
}
}
}
///|
/// Put a node in the place of every selected element.
pub fn Selection::replace_with(self : Selection, node : Node) -> Unit {
for item in self.items {
if item is ElementNode(data) {
ignore(data.replace_with(node))
}
}
}
///|
/// Replace every selected element with its children.
pub fn Selection::unwrap(self : Selection) -> Unit {
for item in self.items {
if item is ElementNode(data) {
ignore(data.unwrap())
}
}
}
///|
/// Wrap every selected element in a fresh element of this name.
pub fn Selection::wrap(self : Selection, name : String) -> Unit {
for item in self.items {
if item is ElementNode(data) {
ignore(data.wrap(name))
}
}
}
///|
/// Drop the children of every selected element.
pub fn Selection::clear(self : Selection) -> Unit {
for item in self.items {
if item is ElementNode(data) {
data.clear()
}
}
}
///|
/// Replace the children of every selected element with one text node.
pub fn Selection::set_text(self : Selection, text : String) -> Unit {
for item in self.items {
if item is ElementNode(data) {
data.clear()
data.append(TextNode(text))
}
}
}
///|
/// Set an attribute on every selected element.
pub fn Selection::set_attr(
self : Selection,
name : String,
value : String,
) -> Unit {
for item in self.items {
if item is ElementNode(data) {
data.set_attr(name, value)
}
}
}
///|
/// Remove an attribute from every selected element.
pub fn Selection::remove_attr(self : Selection, name : String) -> Unit {
for item in self.items {
if item is ElementNode(data) {
ignore(data.remove_attr(name))
}
}
}
///|
/// Add a class to every selected element.
pub fn Selection::add_class(self : Selection, name : String) -> Unit {
for item in self.items {
if item is ElementNode(data) {
data.add_class(name)
}
}
}
///|
/// Remove a class from every selected element.
pub fn Selection::remove_class(self : Selection, name : String) -> Unit {
for item in self.items {
if item is ElementNode(data) {
data.remove_class(name)
}
}
}
///|
/// Fuse adjacent text nodes under every selected element.
pub fn Selection::smooth(self : Selection) -> Unit {
for item in self.items {
if item is ElementNode(data) {
data.smooth()
}
}
}
// The same surface on Element, Node and Document, so a chain never has to
// stop. A node that is not an element has no descendants to search, so its
// query methods answer an empty selection.
///|
/// Narrow to the descendants of this element that match a CSS selector.
pub fn Element::css(self : Element, query : String) -> Selection raise Refused {
selection_of(ElementNode(self)).css(query)
}
///|
/// The descendants of this element whose `id` attribute is `id`.
pub fn Element::by_id(self : Element, id : String) -> Selection {
selection_of(ElementNode(self)).by_id(id)
}
///|
/// The descendants of this element that carry the class `name`.
pub fn Element::by_class(self : Element, name : String) -> Selection {
selection_of(ElementNode(self)).by_class(name)
}
///|
/// The descendants of this element named `name`; `*` answers every element.
pub fn Element::by_tag(self : Element, name : String) -> Selection {
selection_of(ElementNode(self)).by_tag(name)
}
///|
/// The descendants of this element whose `name` attribute is `name`.
pub fn Element::by_name(self : Element, name : String) -> Selection {
selection_of(ElementNode(self)).by_name(name)
}
///|
/// The nearest ancestor of this element, itself included, that matches a CSS selector.
pub fn Element::closest(
self : Element,
query : String,
) -> Selection raise Refused {
selection_of(ElementNode(self)).closest(query)
}
///|
/// Run the selector registered under `name`, over this element's descendants.
pub fn Element::selector(
self : Element,
name : String,
query : String,
) -> Selection raise Refused {
selection_of(ElementNode(self)).selector(name, query)
}
///|
/// Run a selector that was never registered, over this element's descendants.
pub fn Element::custom(
self : Element,
sel : &Selector,
) -> Selection raise Refused {
selection_of(ElementNode(self)).custom(sel)
}
///|
/// Narrow to the descendants of this node that match a CSS selector; a node that is not an element answers empty.
pub fn Node::css(self : Node, query : String) -> Selection raise Refused {
match self {
ElementNode(data) => data.css(query)
_ => Selection::new()
}
}
///|
/// The descendants of this node whose `id` attribute is `id`.
pub fn Node::by_id(self : Node, id : String) -> Selection {
match self {
ElementNode(data) => data.by_id(id)
_ => Selection::new()
}
}
///|
/// The descendants of this node that carry the class `name`.
pub fn Node::by_class(self : Node, name : String) -> Selection {
match self {
ElementNode(data) => data.by_class(name)
_ => Selection::new()
}
}
///|
/// The descendants of this node named `name`.
pub fn Node::by_tag(self : Node, name : String) -> Selection {
match self {
ElementNode(data) => data.by_tag(name)
_ => Selection::new()
}
}
///|
/// The descendants of this node whose `name` attribute is `name`.
pub fn Node::by_name(self : Node, name : String) -> Selection {
match self {
ElementNode(data) => data.by_name(name)
_ => Selection::new()
}
}
///|
/// The nearest ancestor of this node, itself included, that matches a CSS selector.
pub fn Node::closest(self : Node, query : String) -> Selection raise Refused {
match self {
ElementNode(data) => data.closest(query)
_ => Selection::new()
}
}
///|
/// Run the selector registered under `name`, over this node's descendants.
pub fn Node::selector(
self : Node,
name : String,
query : String,
) -> Selection raise Refused {
match self {
ElementNode(data) => data.selector(name, query)
_ => raise UnknownSelector(name~)
}
}
///|
/// Run a selector that was never registered, over this node's descendants.
pub fn Node::custom(self : Node, sel : &Selector) -> Selection raise Refused {
match self {
ElementNode(data) => data.custom(sel)
_ => Selection::new()
}
}
// The document's own surface. `Document::root` is a container, not an element
// in the document, so the verbs that act on one node among its siblings
// (`remove`, `replace_with`, `wrap`, `unwrap`, `parent`, `first`, `last`,
// `eq`, `length`) answer nothing a caller could mean here and are not
// forwarded. What is left is what a whole document can do: search it, read
// it, put top-level nodes in it, and empty it.
///|
/// Narrow with a CSS selector over the whole document. Descendant search from
/// the virtual root, so every element is a candidate.
pub fn Document::css(
self : Document,
query : String,
) -> Selection raise Refused {
self.selection([ElementNode(self.root)]).css(query)
}
///|
/// The elements whose `id` attribute is `id` — `getElementById`, over the
/// whole document.
pub fn Document::by_id(self : Document, id : String) -> Selection {
self.selection([ElementNode(self.root)]).by_id(id)
}
///|
/// The elements carrying the class `name`.
pub fn Document::by_class(self : Document, name : String) -> Selection {
self.selection([ElementNode(self.root)]).by_class(name)
}
///|
/// The elements named `name`; `*` answers every element.
pub fn Document::by_tag(self : Document, name : String) -> Selection {
self.selection([ElementNode(self.root)]).by_tag(name)
}
///|
/// The elements whose `name` attribute is `name`.
pub fn Document::by_name(self : Document, name : String) -> Selection {
self.selection([ElementNode(self.root)]).by_name(name)
}
///|
/// Run the selector registered under `name`, built-in or the caller's own.
pub fn Document::selector(
self : Document,
name : String,
query : String,
) -> Selection raise Refused {
self.selection([ElementNode(self.root)]).selector(name, query)
}
///|
/// Run a selector that was never registered.
pub fn Document::custom(
self : Document,
sel : &Selector,
) -> Selection raise Refused {
self.selection([ElementNode(self.root)]).custom(sel)
}
///|
/// The text of the whole document, concatenated.
pub fn Document::text(self : Document) -> String {
self.selection([ElementNode(self.root)]).text()
}
///|
/// The whole document as markup, in the flavour it was read as.
pub fn Document::html(self : Document) -> String {
self.selection([ElementNode(self.root)]).html()
}
///|
/// The document's top-level nodes — the doctype, comments and processing
/// instructions beside the document element, in document order.
pub fn Document::children(self : Document) -> Selection {
self.selection([ElementNode(self.root)]).children()
}
///|
/// Every node in the document, in document order.
pub fn Document::descendants(self : Document) -> Selection {
self.selection([ElementNode(self.root)]).descendants()
}
///|
/// Add a node at the end of the document: where a comment, a processing
/// instruction or a second document element would go. The usual use is a
/// doctype written before the root, which is what `prepend` is for.
pub fn Document::append(self : Document, node : Node) -> Unit {
self.selection([ElementNode(self.root)]).append(node)
}
///|
/// Add a node before everything else in the document.
pub fn Document::prepend(self : Document, node : Node) -> Unit {
self.selection([ElementNode(self.root)]).prepend(node)
}
///|
/// Empty the document: every top-level node goes, and the document is then
/// what a freshly created one is.
pub fn Document::clear(self : Document) -> Unit {
self.selection([ElementNode(self.root)]).clear()
}
///|
/// Fuse adjacent text nodes everywhere in the document, which is what a
/// writer wants after editing several places.
pub fn Document::smooth(self : Document) -> Unit {
self.selection([ElementNode(self.root)]).smooth()
}