Skip to content

Editing

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

Every edit is a method on Document that takes &mut self. You create nodes, which start detached, then put them where you want them; you move and remove nodes; and you set, add, rename and remove attributes. The code is in crates/parsec/src/edit.rs.

On this page: Creating nodes · Moving nodes · Removing nodes · adopt_template_contents · Attributes · rename · EditError · The rules edits keep · The cost of editing

Two things hold for everything on this page:

  • Every method that takes a NodeId panics on an id that's doc.len() or more.
  • Edits work on the tree. They don't rerun the parser, so nothing gets fixed up for you: if you put a <tr> straight into a <div>, that's where it stays, and that's what the serializer writes.

Creating nodes

Each of these adds a new node to the arena and returns its id. The node has no parent until you insert it.

Method Makes
create_element(&mut self, ns: Ns, name: &str) -> NodeId An element with no attributes. See Names and namespaces.
create_text(&mut self, text: &str) -> NodeId A text node.
create_comment(&mut self, text: &str) -> NodeId A comment.
create_cdata(&mut self, text: &str) -> NodeId A CDATA section. The HTML parser never makes these; they're for XML trees.
create_processing_instruction(&mut self, target: &str, data: &str) -> NodeId A processing instruction, written <?target data?>.

Nothing is checked. A comment containing --> or a processing instruction containing ?> is created as given, and serializes into markup that won't parse back the same way. The strings are copied into the document.

use parsec::{Document, Kind, Ns};

let mut doc = Document::empty();
let p = doc.create_element(Ns::Html, "P");
let t = doc.create_text("fish & chips");
let c = doc.create_comment(" note ");

assert_eq!(doc.kind(p), Kind::Element { ns: Ns::Html, name: "p" });
assert_eq!(doc.parent(t), None);

doc.append(doc.root(), p).unwrap();
doc.append(p, t).unwrap();
doc.append(p, c).unwrap();
assert_eq!(doc.inner_html(0), "<p>fish &amp; chips<!-- note --></p>");

Names and namespaces

create_element treats the name by namespace:

  • Ns::Html: lowercased, as document.createElement does. "DIV" makes div. An HTML template gets its own contents node, as a parsed one does.
  • Ns::Svg and Ns::MathMl: kept as you wrote it. "linearGradient" stays linearGradient. Write the case the element should serialize with.
  • Ns::Xml: kept exactly, prefix and all ("dc:creator"), and never treated as an HTML element whatever the name.

The namespace is what makes an element SVG; the name doesn't. create_element(Ns::Html, "svg") makes an HTML element that happens to be called svg. It serializes the same, but selectors and the tree builder's rules treat it as HTML, and you can't change an element's namespace later. When you build SVG or MathML by hand, give every element in it Ns::Svg or Ns::MathMl.

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

let mut doc = parsec::parse_fragment("", Ns::Html, "div", false);
let svg = doc.create_element(Ns::Svg, "svg");
let grad = doc.create_element(Ns::Svg, "linearGradient");
doc.set_attr(grad, "gradientUnits", "userSpaceOnUse");
doc.append(svg, grad).unwrap();
doc.append(0, svg).unwrap();

assert_eq!(
    doc.inner_html(0),
    r#"<svg><linearGradient gradientUnits="userSpaceOnUse"></linearGradient></svg>"#
);
// SVG type selectors keep their case, as in a browser.
assert_eq!(SelectorList::parse("linearGradient").unwrap().select(&doc, 0).len(), 1);
assert!(SelectorList::parse("lineargradient").unwrap().select(&doc, 0).is_empty());

Moving nodes

insert_before

pub fn insert_before(&mut self, parent: NodeId, child: NodeId, before: Option<NodeId>)
    -> Result<(), EditError>

Moves child into parent, just before before, or at the end when before is None. If child is somewhere else in the tree, it's taken out of there first; nodes move, they're never copied.

It checks, in this order, and changes nothing if a check fails:

  1. parent is an element, a document or a fragment. Otherwise Err(EditError::NotAParent).
  2. before, if given, is a child of parent. Otherwise Err(EditError::NotAChild).
  3. child isn't parent or one of its ancestors. Otherwise Err(EditError::Cycle).

If before is child itself, there's nothing to do and it returns Ok(()).

append

pub fn append(&mut self, parent: NodeId, child: NodeId) -> Result<(), EditError>

The same as insert_before(parent, child, None): child becomes parent's last child.

use parsec::{EditError, Ns};

let mut doc = parsec::parse_fragment("<ol><li>b</li></ol>", Ns::Html, "div", false);
let ol = doc.first_child(0).unwrap();
let b = doc.first_child(ol).unwrap();

let a = doc.create_element(Ns::Html, "li");
let text = doc.create_text("a");
doc.append(a, text).unwrap();
doc.insert_before(ol, a, Some(b)).unwrap();
assert_eq!(doc.inner_html(0), "<ol><li>a</li><li>b</li></ol>");

// Moving b to the front takes it out of its old place.
doc.insert_before(ol, b, doc.first_child(ol)).unwrap();
assert_eq!(doc.inner_html(0), "<ol><li>b</li><li>a</li></ol>");

// The three refusals.
assert_eq!(doc.append(b, ol), Err(EditError::Cycle));
assert_eq!(doc.append(text, b), Err(EditError::NotAParent));
assert_eq!(doc.insert_before(b, a, Some(ol)), Err(EditError::NotAChild));

What isn't checked

The DOM refuses more than Parsec does today. insert_before doesn't check what kind of node child is, so it will let you:

  • put a second element, or text, directly under a Document node;
  • put a doctype inside an element;
  • move a Document or Fragment node, including a template's contents node, under an element, as long as that doesn't make a cycle. Even the root can be moved under a detached element.

None of these breaks memory safety or panics, but the tree stops meaning what the HTML Standard says it means: :root stops matching once the root has a parent, for instance.

Warning

Keep to elements, text, comments and processing instructions as children, and leave the root and contents nodes where the parser put them.

Removing nodes

pub fn remove(&mut self, id: NodeId)

Takes id out of the tree. It does nothing if id has no parent (the root, or a node that's already detached).

What removal means

A removed node isn't freed. It stays in the arena with its whole subtree still hanging off it, its id stays valid, and you can insert it again. The memory goes when the Document is dropped.

use parsec::Ns;

let mut doc = parsec::parse_fragment("<p>a</p><p>b <em>c</em></p>", Ns::Html, "div", false);
let second = doc.last_child(0).unwrap();

doc.remove(second);
assert_eq!(doc.inner_html(0), "<p>a</p>");
assert_eq!(doc.parent(second), None);
// The subtree came with it.
assert_eq!(doc.text(second), "b c");

doc.insert_before(0, second, doc.first_child(0)).unwrap();
assert_eq!(doc.inner_html(0), "<p>b <em>c</em></p><p>a</p>");

Because ids aren't reused, an id you kept still names the same node after any number of edits. What it can't tell you is whether that node is still in the tree; check parent for that. Handles that know when their node was removed are planned (#12).

adopt_template_contents

pub fn adopt_template_contents(&mut self)

Moves every HTML <template>'s contents in under the template element itself, at the end of its children, and leaves each contents node empty. It covers every template in the arena, including templates nested inside other templates' contents and templates you've detached.

The HTML Standard keeps template contents out of the tree (see The document tree). lxml and Beautiful Soup don't, and scrapers usually want them visible. After this call, children, text and selectors see what was in each template. Serialization doesn't change, because the serializer writes a template's contents and then its children.

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

let mut doc = parsec::parse_fragment("<template><b>x</b></template>", Ns::Html, "div", false);
let find_b = SelectorList::parse("template > b").unwrap();
assert!(find_b.select(&doc, 0).is_empty());

doc.adopt_template_contents();
assert_eq!(find_b.select(&doc, 0).len(), 1);
assert_eq!(doc.inner_html(0), "<template><b>x</b></template>");

Attributes

set_attr

pub fn set_attr(&mut self, id: NodeId, name: &str, value: &str)

Sets an attribute, as setAttribute does: if the element already has a no-namespace attribute with that name, its value is replaced; otherwise the attribute is added at the end.

  • The name is ASCII-lowercased, unless the element is Ns::Xml. That's how the parser stores names too, and the serializer puts SVG's and MathML's case back for the names in the spec's table, so set_attr(svg, "viewBox", ..) writes viewBox. A mixed-case SVG name that isn't in the table (fooBar) is written lowercase; use add_attr if you need it kept.
  • Only attributes with AttrNs::None are looked at. On an SVG <use> with xlink:href, set_attr(id, "xlink:href", ..) adds a second, no-namespace attribute rather than changing the namespaced one, and the element then serializes with both. To change a namespaced attribute, rebuild the list with set_attrs.
  • Does nothing if id isn't an element.

remove_attr

pub fn remove_attr(&mut self, id: NodeId, name: &str) -> bool

Removes the attribute called name and returns true, or returns false if there wasn't one or id isn't an element. The name is lowercased as for set_attr. It compares with the stored name in any namespace, and a namespaced attribute's stored name has its prefix (xlink:href), so remove_attr(id, "xlink:href") removes the XLink one while remove_attr(id, "href") doesn't.

use parsec::Ns;

let mut doc = parsec::parse_fragment(r#"<a href="x">a</a>"#, Ns::Html, "div", false);
let a = doc.first_child(0).unwrap();

doc.set_attr(a, "HREF", "y");      // replaces, name lowercased
doc.set_attr(a, "title", "t");     // adds at the end
assert_eq!(doc.outer_html(a), r#"<a href="y" title="t">a</a>"#);

assert!(doc.remove_attr(a, "href"));
assert!(!doc.remove_attr(a, "href"));
assert_eq!(doc.outer_html(a), r#"<a title="t">a</a>"#);

add_attr

pub fn add_attr(&mut self, id: NodeId, ns: AttrNs, name: &str, value: &str)

Appends an attribute exactly as given, without lowercasing the name and without replacing one of the same name. It's meant for building XML, where you've already checked names. Adding a duplicate is your responsibility, and both copies will be written out. For a namespaced attribute, give the written name with its prefix (xlink:href with AttrNs::XLink); attrs then reports href. Does nothing if id isn't an element.

set_attrs

pub fn set_attrs(&mut self, id: NodeId, attrs: &[(AttrNs, &str, &str)])

Replaces all of the element's attributes with attrs, in that order, names exactly as given. Nothing is lowercased or deduplicated: on an HTML element, pass lowercase names, or selectors and set_attr won't find them. Passing an empty slice removes every attribute. Does nothing if id isn't an element.

rename_attr

pub fn rename_attr(&mut self, id: NodeId, index: usize, ns: AttrNs, name: &str)

Renames the element's indexth attribute (counting from 0, in the order attrs gives) to name, exactly as given, and sets its namespace to ns. Does nothing if there's no such attribute or id isn't an element.

remove_attr_at

pub fn remove_attr_at(&mut self, id: NodeId, index: usize) -> bool

Removes the element's indexth attribute and returns true, or returns false if there's no such attribute or id isn't an element.

use parsec::{AttrNs, Document, Ns};

let mut doc = Document::empty();
let entry = doc.create_element(Ns::Xml, "dc:Creator");
doc.append(0, entry).unwrap();
doc.add_attr(entry, AttrNs::None, "ID", "up");
doc.add_attr(entry, AttrNs::None, "id", "lo");
doc.add_attr(entry, AttrNs::Host(3), "x:href", "/p");
assert_eq!(doc.outer_html(entry), r#"<dc:Creator ID="up" id="lo" x:href="/p"></dc:Creator>"#);

doc.rename(entry, "Creator");
doc.rename_attr(entry, 2, AttrNs::None, "href");
assert!(doc.remove_attr_at(entry, 0));
assert_eq!(doc.outer_html(entry), r#"<Creator id="lo" href="/p"></Creator>"#);

doc.set_attrs(entry, &[(AttrNs::None, "a", "1"), (AttrNs::None, "b", "2")]);
assert_eq!(doc.html_alone(entry), r#"<Creator a="1" b="2">"#);

rename

pub fn rename(&mut self, id: NodeId, name: &str)

Renames an element, keeping its namespace, attributes and children. The name is lowercased for Ns::Html and kept as given for the rest, as in create_element, and the element's tag number follows the new name. Does nothing if id isn't an element.

Renaming doesn't add or remove template contents. A div renamed to template has no contents node; a template renamed to div keeps its contents node (which template_contents still reports) but no longer writes it out.

use parsec::Ns;

let mut doc = parsec::parse_fragment("<b class=x>bold</b>", Ns::Html, "div", false);
let b = doc.first_child(0).unwrap();
doc.rename(b, "STRONG");
assert_eq!(doc.inner_html(0), r#"<strong class="x">bold</strong>"#);
assert_eq!(doc.known_tag(b), parsec::tag_number("strong"));

EditError

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditError { Cycle, NotAParent, NotAChild }

Why insert_before or append refused. It implements std::error::Error and Display:

Variant Display
Cycle a node can't be inserted into itself or its descendants
NotAParent only an element, a document or a fragment can have children
NotAChild the node to insert before isn't a child of the parent

The rules edits keep

  • No cycles. insert_before and append never make a node its own ancestor.
  • Template contents stay separate, unless you call adopt_template_contents or move nodes in yourself. A template's contents node has no parent.
  • Shared attributes stay shared only until you change them. When the parser rebuilds formatting elements (the <b> in <p><b>a</p>t is opened again around t), the copies share their original's attribute list. Every attribute edit writes a fresh copy of the list for the one element you changed, so the others are left alone.
  • Strings don't move. A string an edit adds gets its own allocation, and nothing an edit does moves a string the document already holds. Within Rust's borrow rules you can't observe the difference, but code that keeps raw pointers to strings across edits, as a binding to another language might, can rely on it.

The cost of editing

Edits are cheap but not free, and none of them gives memory back:

  • Creating a node appends to the arena.
  • Inserting, appending and removing relink a handful of indexes, except that insert_before walks up from parent to the root to check for a cycle, so it costs the depth of the tree.
  • Every attribute edit copies the element's whole attribute list to the end of the document's attribute vector, and stores the new strings separately. Setting many attributes one at a time on an element with many attributes costs more than one set_attrs call.
  • Removed nodes and replaced attribute lists stay allocated until the Document is dropped. A long-running process that edits one document heavily will see it grow; parse a fresh one, or serialize and reparse, when that matters.

See also

Clone this wiki locally