XML
WorksParse an XML document into a tree you can walk, and build one node by node — with one sharp edge to know about before you write any output.
- Version
0.3.6zef:raku-community-modules- Depends
- nothing outside the core
- License
- Artistic-2.0
- Its own test suite
- 15 files, green
- Checked
- 2026-08-24 against Raku++ 3.7.0 and Rakudo 2026.08
Install it #
$ rakupp install XMLzef install XML writes the same store; either installer leaves the module usable by both engines.
What it is for #
Something upstream still speaks XML — an RSS feed, a SOAP endpoint, an Office document, a .plist, half the configuration formats written before 2010. This module parses it into a tree of XML::Element and XML::Text nodes, and lets you build one the same way:
use XML;
my $doc = from-xml(q:to/XML/);
<catalog>
<book id="1"><title>Learning Raku</title><price>39.99</price></book>
<book id="2"><title>Raku Recipes</title><price>44.50</price></book>
</catalog>
XML
say $doc.root.name;
say $doc.elements(:TAG<book>).elems;
for $doc.elements(:TAG<book>) -> $b {
say $b.attribs<id>, ': ', $b.elements(:TAG<title>, :SINGLE).contents.join;
}catalog
2
1: Learning Raku
2: Raku Recipesfrom-xml takes a string. Its neighbours are from-xml-file for a path and from-xml-stream for a handle.
Finding things #
elements is the workhorse. It looks at the direct children of a node and filters them: :TAG<name> by element name, any other named pair by attribute value, and :SINGLE to get one node back instead of a list.
use XML;
my $doc = from-xml('<a><b x="1"><c>deep</c></b><b x="2"/></a>');
say $doc.root.nodes.elems;
say $doc.root[0].name;
say $doc.root[0]<x>;
say $doc.root.elements(:TAG<b>, :x<2>).elems;
say $doc.root.lookfor(:TAG<c>).map(*.contents.join).join(',');2
b
1
1
deepThree shorthands are worth learning because they shorten nearly every walk: $node[0] is the first child, $node<attr> is an attribute, and lookfor searches the whole subtree rather than only the direct children — that last one is the difference between elements and finding something nested at unknown depth.
Building a document #
Nodes are constructed and appended. set writes an attribute; append adds a child:
use XML;
my $item = XML::Element.new(name => 'item');
$item.set('sku', 'A-17');
$item.append(XML::Text.new(text => 'Widget'));
say ~$item;
my $doc = XML::Document.new(XML::Element.new(name => 'order'));
$doc.root.append($item);
say ~$doc;<item sku="A-17">Widget</item>
<?xml version="1.0"?><order><item sku="A-17">Widget</item></order>Stringifying an XML::Document adds the declaration; stringifying an element gives you just that element.
The sharp edge: nothing is escaped for you #
XML::Text stores text verbatim and writes it back verbatim. So does set. Put an ampersand or an angle bracket into either and the document you produce is not valid XML — and this module will not read it back:
$e.set('title', 'Tom & Jerry');
$e.append(XML::Text.new(text => '5 < 6'));
say ~$e; # <note title="Tom & Jerry">5 < 6</note>
from-xml(~$e); # dies: could not parse XMLThat is by design — the module treats the text attribute as the document's own bytes, entities included — but it means escaping on the way in is your job. Four substitutions cover it:
use XML;
sub xml-escape(Str $s) {
$s.subst('&', '&', :g)
.subst('<', '<', :g)
.subst('>', '>', :g)
.subst('"', '"', :g)
}
my $e = XML::Element.new(name => 'note');
$e.set('title', xml-escape('Tom & Jerry <"quoted">'));
$e.append(XML::Text.new(text => xml-escape('5 < 6 & 7 > 6')));
say ~$e;
my $back = from-xml(~$e);
say $back.root.attribs<title>;
say $back.root.nodes[0].string;<note title="Tom & Jerry <"quoted">">5 < 6 & 7 > 6</note>
Tom & Jerry <"quoted">
5 < 6 & 7 > 6Reading has the same asymmetry, and the last two lines show it. .attribs and .contents give you the raw text with entities still in it; .string on a text node decodes them. Use .string when you want the value a human meant, and .contents when you want the document's own bytes.
Where the two engines differ #
Nothing on this page. Every example prints the same bytes under Raku++ and under Rakudo, twice on each — the escaping behaviour above included: the unescaped document fails to re-parse on both engines, and the escaped one round-trips on both.
What was run to put this page here #
- Parse — every file of the distribution is parsed by Raku++ itself.
- Install —
rakupp install XML, no dependencies to pull. - Test — the distribution's own suite: 15 files, green.
- Run — every example on this page, twice under each engine, as the site is built.
XML was one of the nine distributions the campaign's first wide measurement found outright broken: from-xml("<a><b>hi</b></a>").root.name answered 'a a' instead of 'a'. Three general fixes closed it, and the first is the one worth reading.
$<name> in match position is a backreference, not a capture. XML's close-tag rule is '</' $<name> '>' — match the same text the open tag captured. Raku++ was consuming only the $ and then re-parsing <name> as a fresh named capture, so every tag name was captured twice and the root's name came back doubled. That is a regex-engine fix, not an XML one, and it applies to every grammar in the language.
The other two were parser gaps the module happened to reach: ::(EXPR) as a parameter constraint — method reparent(::(q<XML::Element>) $parent) — was accepted in expression position but not in a signature; and a coercion-type attribute, has IO::Path() $.filename, was rejected outright with "Attribute $!filename not declared".