Skip to content

Moving from Lexbor

James Conway edited this page Nov 4, 2026 · 2 revisions

If you have code on Lexbor's C API, this page shows the Parsec call for each thing you do, in Rust. It covers what bindings use most: parsing a document or a fragment, finding elements with selectors, reading text, serializing, getting and setting attributes, and inserting and removing nodes.

On this page: What changes in how you think about it · The calls side by side · Parsing a document · Attributes · Inserting and removing nodes · Setting text · Fragments · Selectors that behave differently · What isn't there yet

Note

Parsec doesn't have a C API yet, so for now moving means calling Parsec from Rust.

A C API is planned (#20), with prebuilt libraries (#21), and so is a fuller migration guide that covers it (#22). If you're on selectolax, a Python package with a familiar API is planned too (#23).

The Lexbor function names below are from its headers at commit 145872a, and each links to its declaration file.

Not sure where to start? Find what your code does in Lexbor today, and follow it to the page you need:

flowchart TD
    start{"What does your code do with Lexbor?"}
    start -->|"parses documents or fragments"| parsing["Parsing"]
    start -->|"queries with CSS selectors"| selectors["Selectors"]
    start -->|"reads text, attributes, children"| tree["The document tree"]
    start -->|"inserts, removes, sets attributes"| editing["Editing"]
    start -->|"serializes back to HTML"| ser["Serialization"]
    start -->|"runs the tokenizer with a callback"| tok["Tokenizer"]
    start -->|"shares documents across threads"| threads["Thread safety and memory safety"]
    start -->|"is called from C, Python or JavaScript"| later["Not yet: #20, #23, #24"]
Loading

Each box is a page in the sidebar: Parsing, Selectors, The document tree, Editing, Serialization, Tokenizer, Thread safety and memory safety. This page has the calls side by side for all of them.

What changes in how you think about it

  • Nothing to create or destroy. There are no _create, _init or _destroy calls. A Document frees everything when it's dropped. A SelectorList is an ordinary value you can keep.
  • Nodes are NodeIds, not pointers. A NodeId is a u32 index into the document. You read and change nodes through the document: doc.text(id), not node->.... An id can't dangle: a removed node stays in the document until the document goes, so using it later is never a use-after-free. It also means memory for removed nodes isn't given back early.
  • Errors are Results, and there are few of them. Parsing doesn't fail on bad markup, only on input over 512 MiB or a budget running out. Edits that would break the tree return EditError. Most other calls can't fail, and those that do nothing on the wrong kind of node say so.
  • Strings are &str. Lengths come with them, and they're borrowed from the document, so they're good while you hold the document and don't edit it.
  • Input is text. Parsec takes UTF-8 as &str and doesn't sniff encodings yet (#5).

The calls side by side

Every Lexbor call on this page, with its Parsec equivalent
You do this in Lexbor In Parsec Notes
lxb_html_document_create, lxb_html_document_parse(doc, html, len) (document.h) parsec::parse(html, scripting) Returns the document. parse_with adds a budget and returns Result.
lxb_html_document_destroy(doc) drop the Document
lxb_html_parser_scripting_set (parser.h) the scripting argument, or Options::scripting
lxb_html_document_parse_chunk_begin and friends not yet #6
lxb_html_document_parse_fragment(doc, element, html, len) parsec::parse_fragment(html, ns, "div", scripting) Takes the context's namespace and name, not an element, and returns a separate document. See Fragments.
lxb_css_parser_create, lxb_css_selectors_parse(parser, s, len) (css/selectors/selectors.h) SelectorList::parse(s) Result<SelectorList, SelectorError>.
lxb_selectors_create, lxb_selectors_find(sel, root, list, cb, ctx) (selectors/selectors.h) list.select(&doc, root) Returns a Vec<NodeId> in document order instead of calling back. root itself is never included. Stopping at the first match isn't there yet (#18).
lxb_selectors_match_node(sel, node, list, cb, ctx) list.matches(&doc, node, None) Returns bool.
lxb_dom_node_text_content(node, &len) (node.h) doc.text(node) Returns a new String.
lxb_dom_node_text_content_set(node, text, len) remove the children, then create_text and append See Setting text.
lxb_html_serialize_tree_str(node, &str) (serialize.h) doc.outer_html(node)
lxb_html_serialize_deep_str(node, &str) doc.inner_html(node)
lxb_html_serialize_pretty_tree_str not yet #9
lxb_dom_element_get_attribute(el, name, len, &vlen) (element.h) doc.attrs(el).find(|a| a.1 == name).map(|a| a.2) No shortcut method yet.
lxb_dom_element_has_attribute doc.attrs(el).any(|a| a.1 == name)
lxb_dom_element_set_attribute(el, name, len, value, vlen) doc.set_attr(el, name, value) Lowercases the name, replaces or adds.
lxb_dom_element_remove_attribute(el, name, len) doc.remove_attr(el, name) Returns whether there was one.
lxb_dom_element_first_attribute and iterating doc.attrs(el) (AttrNs, name, value) in source order.
lxb_dom_element_local_name(el, &len) doc.lower_name(el), or doc.kind(el) kind gives the name with SVG's case.
lxb_dom_node_first_child, lxb_dom_node_next, lxb_dom_node_parent doc.first_child(n), doc.next_sibling(n), doc.parent(n) Option<NodeId>. Also children(n).
lxb_dom_document_create_element(doc, name, len, NULL) (document.h) doc.create_element(Ns::Html, name) Give the namespace.
lxb_dom_document_create_text_node(doc, text, len) doc.create_text(text)
lxb_dom_node_insert_child(to, node) doc.append(to, node) Result; refuses cycles.
lxb_dom_node_insert_before(to, node) doc.insert_before(parent_of_to, node, Some(to)) Lexbor's to is the sibling; Parsec takes the parent and the sibling.
lxb_dom_node_insert_after(to, node) doc.insert_before(parent_of_to, node, doc.next_sibling(to))
lxb_dom_node_remove(node) doc.remove(node)
lxb_dom_node_destroy, lxb_dom_node_destroy_deep doc.remove(node) Nothing to free; the memory goes with the document.

Parsing a document

use parsec::css::SelectorList;

// lxb_html_document_create + lxb_html_document_parse
let doc = parsec::parse("<div><p class=a>one</p><p>two</p></div>", false);

// lxb_css_parser_create + lxb_css_selectors_parse + lxb_selectors_find
let list = SelectorList::parse("p.a").unwrap();
for p in list.select(&doc, doc.root()) {
    // lxb_dom_node_text_content
    assert_eq!(doc.text(p), "one");
    // lxb_html_serialize_tree_str
    assert_eq!(doc.outer_html(p), r#"<p class="a">one</p>"#);
}
// lxb_html_document_destroy: nothing, doc is dropped here.

Attributes

use parsec::css::SelectorList;

let mut doc = parsec::parse(r#"<a href="/old" target=_blank>link</a>"#, false);
let a = SelectorList::parse("a").unwrap().select(&doc, doc.root())[0];

// lxb_dom_element_get_attribute
let href = doc.attrs(a).find(|a| a.1 == "href").map(|a| a.2.to_string());
assert_eq!(href.as_deref(), Some("/old"));

// lxb_dom_element_has_attribute
assert!(doc.attrs(a).any(|a| a.1 == "target"));

// lxb_dom_element_set_attribute
doc.set_attr(a, "href", "/new");
// lxb_dom_element_remove_attribute
assert!(doc.remove_attr(a, "target"));

assert_eq!(doc.outer_html(a), r#"<a href="/new">link</a>"#);

Inserting and removing nodes

Lexbor's insert_before(to, node) puts node before the sibling to. Parsec's insert_before(parent, node, before) takes the parent too, so you look it up:

use parsec::css::SelectorList;
use parsec::Ns;

let mut doc = parsec::parse("<ul><li id=b>b</li></ul>", false);
let b = SelectorList::parse("#b").unwrap().select(&doc, doc.root())[0];
let ul = doc.parent(b).unwrap();

let new_li = |doc: &mut parsec::Document, text: &str| {
    let li = doc.create_element(Ns::Html, "li");
    let t = doc.create_text(text);
    doc.append(li, t).unwrap();
    li
};

// lxb_dom_node_insert_before(b, a): a goes before b.
let a = new_li(&mut doc, "a");
doc.insert_before(ul, a, Some(b)).unwrap();

// lxb_dom_node_insert_after(b, c): c goes after b.
let c = new_li(&mut doc, "c");
doc.insert_before(ul, c, doc.next_sibling(b)).unwrap();

// lxb_dom_node_insert_child(ul, d): d goes last.
let d = new_li(&mut doc, "d");
doc.append(ul, d).unwrap();

// lxb_dom_node_remove(b), or lxb_dom_node_destroy_deep(b).
doc.remove(b);

assert_eq!(doc.inner_html(ul), "<li>a</li><li>c</li><li>d</li>");

Setting text

There's no textContent setter. Remove the children and add one text node:

use parsec::css::SelectorList;
use parsec::{Document, NodeId};

fn set_text(doc: &mut Document, el: NodeId, text: &str) {
    let kids: Vec<NodeId> = doc.children(el).collect();
    for kid in kids {
        doc.remove(kid);
    }
    let t = doc.create_text(text);
    doc.append(el, t).unwrap();
}

let mut doc = parsec::parse("<p>old <b>bold</b></p>", false);
let p = SelectorList::parse("p").unwrap().select(&doc, doc.root())[0];
set_text(&mut doc, p, "new & improved");
assert_eq!(doc.outer_html(p), "<p>new &amp; improved</p>");

Fragments

lxb_html_document_parse_fragment parses into the document you give it, under a context element you made. Parsec's parse_fragment takes the context's namespace and name and returns a document of its own, whose root is a Fragment node holding the result:

use parsec::Ns;

// As if setting innerHTML on a <tbody>.
let frag = parsec::parse_fragment("<tr><td>1<td>2", Ns::Html, "tbody", false);
assert_eq!(frag.inner_html(frag.root()), "<tr><td>1</td><td>2</td></tr>");

There's no function to move nodes from one document into another yet. If you need the equivalent of setting innerHTML on an element of an existing document, copy the fragment's nodes across. This helper does it for elements, text, comments and processing instructions, which is everything a fragment parse produces; it recurses once per level, so it's for fragments of ordinary depth.

The import and set_inner_html helpers, about 70 lines
use parsec::css::SelectorList;
use parsec::{AttrNs, Document, Kind, NodeId};

/// Copies `node` and everything under it from `src` into `dst`, detached,
/// and returns the copy.
fn import(dst: &mut Document, src: &Document, node: NodeId) -> Option<NodeId> {
    let copy = match src.kind(node) {
        Kind::Element { ns, name } => {
            let el = dst.create_element(ns, name);
            // The stored name for plain attributes, the written one (with
            // its prefix) for namespaced ones: what the parser would store.
            let attrs: Vec<(AttrNs, String, String)> = src
                .attrs(node)
                .zip(src.attrs_written(node))
                .map(|((ns, stored, value), (_, written, _))| {
                    let name = if ns == AttrNs::None { stored } else { written };
                    (ns, name.to_string(), value.to_string())
                })
                .collect();
            let borrowed: Vec<(AttrNs, &str, &str)> =
                attrs.iter().map(|(ns, n, v)| (*ns, n.as_str(), v.as_str())).collect();
            dst.set_attrs(el, &borrowed);
            if let (Some(from), Some(to)) = (src.template_contents(node), dst.template_contents(el)) {
                for child in src.children(from).collect::<Vec<_>>() {
                    if let Some(c) = import(dst, src, child) {
                        dst.append(to, c).unwrap();
                    }
                }
            }
            el
        }
        Kind::Text(t) => dst.create_text(t),
        Kind::Comment(c) => dst.create_comment(c),
        Kind::ProcessingInstruction { target, data } => dst.create_processing_instruction(target, data),
        Kind::Cdata(t) => dst.create_cdata(t),
        Kind::Document | Kind::Fragment | Kind::Doctype { .. } => return None,
    };
    for child in src.children(node).collect::<Vec<_>>() {
        if let Some(c) = import(dst, src, child) {
            dst.append(copy, c).unwrap();
        }
    }
    Some(copy)
}

/// Replaces `el`'s children with `html`, parsed in `el`'s context.
fn set_inner_html(doc: &mut Document, el: NodeId, html: &str) {
    let (ns, name) = match doc.kind(el) {
        Kind::Element { ns, name } => (ns, name.to_string()),
        _ => return,
    };
    let frag = parsec::parse_fragment(html, ns, &name, false);
    for kid in doc.children(el).collect::<Vec<_>>() {
        doc.remove(kid);
    }
    for kid in frag.children(frag.root()).collect::<Vec<_>>() {
        if let Some(copy) = import(doc, &frag, kid) {
            doc.append(el, copy).unwrap();
        }
    }
}

fn main() {
    let mut doc = parsec::parse("<table><tbody><tr><td>old</td></tr></tbody></table>", false);
    let tbody = SelectorList::parse("tbody").unwrap().select(&doc, doc.root())[0];
    set_inner_html(&mut doc, tbody, "<tr><td>a<td>b</tr>");
    assert_eq!(doc.inner_html(tbody), "<tr><td>a</td><td>b</td></tr>");
    assert_eq!(SelectorList::parse("td").unwrap().select(&doc, doc.root()).len(), 2);
}

Selectors that behave differently

Most selectors match the same in both. The differences you're likely to meet, all listed on Selectors:

  • :scope matches the element you query from in Parsec. Lexbor parses it and never matches it (lexbor#257).
  • :hover, :active and :focus never match in Parsec. Lexbor matches them on elements that have an attribute of the same name (selectors.c).
  • Selectors Lexbor parses but never matches, such as :lang(), :dir(), :valid and pseudo-elements, are errors in Parsec until they're implemented, so a query can't quietly find nothing.
  • :lexbor-contains() is Lexbor's own and Parsec refuses it. Filter on doc.text(id) instead.
  • [href] means an href in no namespace, as in a browser; use [*|href] to include SVG's xlink:href.
  • select never includes the node you run it on. If you used Lexbor's option to match the root too, test the root with matches as well.

What isn't there yet

Each has an issue: chunked parsing (#6), encoding sniffing and decoding (#5, #25), parse errors (#7), pretty serialization (#9), first-match queries (#18), the CSS parser and CSSOM (#28), URL parsing (#26), and the C API itself (#20). Not tracked by any issue today: cloning nodes, importing between documents, and DOM mutation callbacks.

If something you rely on in Lexbor isn't on this page, tell us in Discussions; that's how the guide in #22 gets its list.

See also

Clone this wiki locally