# xml

> Parse XML, look things up with simple paths, change a document in place and write it back.

This is the Markdown version of https://osl-lang.org/packages/xml. Every page of the OSL docs has one: add .md to its path. https://osl-lang.org/llms.txt lists them all.

```osl
import "std:xml"
```

`xml` reads, queries, edits and writes XML. Reach for it with RSS and Atom feeds, SVG, SOAP-style APIs and older config files. You parse text into a `Document` and ask it questions with short paths such as `feed>entry>title`.

```osl
import "std:xml"
```

## Parse and look things up

`xml.parse` returns a [result](/learn/errors.md) holding a document. `text` gives an element's own text and `attr` an attribute, both `null` when nothing matches.

```osl
import "std:xml"

string text = `<library name="City">
  <book id="1"><title>Dune</title><year>1965</year></book>
  <book id="2"><title>Emma</title><year>1815</year></book>
</library>`

auto doc = xml.parse(text).unwrap()
log doc.text("library>book>title")
log doc.attr("library", "name")
log doc.attr("book", "id")
log doc.text("missing")
log doc.has("year") doc.has("author")
```

Output:

```text
Dune
City
1
null
true false
```

A path is a list of element names joined with `>`. Each name matches at any depth below the one before, so `book>title` works without spelling out `library`. The query returns the first match in document order.

Try it: ask for `doc.attr("book", "missing")` and see `null`.

## Every match

`findAll` returns each matching element as a record with `name`, `text`, `attrs` and `children`.

```osl
import "std:xml"

auto doc = xml.parse(`<lib><book id="1">Dune</book><book id="2">Emma</book></lib>`).unwrap()
for b in doc.findAll("book") (
  log b.attrs["id"] b.text
)
log doc.findAll("book").len
log doc.count("lib")
log doc.findAll("magazine")
```

Output:

```text
1 Dune
2 Emma
2
2
[]
```

`count(path)` counts the child elements of the first match, here the two books in `lib`. To count matches of a path, use `findAll(path).len`.

## Errors

Malformed XML is a `ParseError` with a line number. A file that doesn't exist is `NotFoundError`.

```osl
import "std:xml"

log xml.isValid("<a><b></a>") xml.isValid("<a/>")
switch xml.parse("<a><b></a>") (
  case ok(d)
    log "parsed"
  case err(e)
    log e.kind
)
log xml.parseFile("no-such.xml").unwrapErr().kind
```

Output:

```text
false true
ParseError
NotFoundError
```

## Edit in place

`setText`, `setAttr`, `remove` and `clear` change the document, and `toStr` writes it back. Editing a path that matches nothing is a `NotFoundError`, so a typo doesn't silently do nothing. Attributes are written in sorted order and text is escaped.

```osl
import "std:xml"

auto doc = xml.parse(`<config><name>old</name><debug>1</debug><tmp>x</tmp></config>`).unwrap()
doc.setText("name", "new & improved").unwrap()
doc.setAttr("config", "version", 2).unwrap()
doc.remove("tmp").unwrap()
doc.clear("debug").unwrap()
log doc.toStr()
log doc.setText("nope", "x").unwrapErr().kind
```

Output:

```text
<config version="2"><name>new &amp; improved</name><debug/></config>
NotFoundError
```

Try it: set the text of `"nope"` and read the error kind.

## Real-world: reading a feed

Walk the records `findAll` gives you and collect what you need:

```osl
import "std:xml"

string feed = `<rss><channel><title>News</title>
<item><title>First</title><link>http://x/1</link></item>
<item><title>Second</title><link>http://x/2</link></item>
</channel></rss>`

auto doc = xml.parse(feed).unwrap()
log "feed:" doc.text("channel>title")
for item in doc.findAll("item") (
  string title = ""
  for c in item.children (
    if c.name == "title" ( title = c.text )
  )
  log "-" title
)
```

Output:

```text
feed: News
- First
- Second
```

> **Note**
>
> `doc.text("channel>title")` finds the channel's title, because the channel's own title is the first `title` below `channel`. The item titles come later in the file.

## The whole tree

`root()` returns the root element with all its children as one nested record. That is handy for converting a document to JSON:

```osl
import "std:xml"
import "std:json"

auto doc = xml.parse(`<p class="x">hi <b>there</b></p>`).unwrap()
log json.stringify(doc.root())
```

Output:

```text
{"attrs":{"class":"x"},"children":[{"attrs":{},"children":[],"name":"b","text":"there"}],"name":"p","text":"hi"}
```

**Quiz.** `doc.text("book>title")` when the file has three books returns...

- [ ] A list of three titles.
- [x] The first matching title.
- [ ] An error.

Answer: `text`, `attr` and `find` return the first match. Use `findAll` for every match.

### Exercise: List the items

Print `id title` for every item. The title is the item's own text.

Starting code:

```osl
import "std:xml"

string source = [
  `<list>`,
  `<item id="1">Apple</item>`,
  `<item id="2">Pear</item>`,
  `<item id="3">Fig</item>`,
  `</list>`
].join("")
auto doc = xml.parse(source).unwrap()
log doc.count("list")
```

It should print:

```text
1 Apple
2 Pear
3 Fig
```

Hint 1: `doc.findAll("item")` gives records.

Hint 2: A record has `attrs` and `text`.

A solution:

```osl
import "std:xml"

string source = [
  `<list>`,
  `<item id="1">Apple</item>`,
  `<item id="2">Pear</item>`,
  `<item id="3">Fig</item>`,
  `</list>`
].join("")
auto doc = xml.parse(source).unwrap()
for item in doc.findAll("item") (
  log item.attrs["id"] item.text
)
```

## API reference

## API reference

Every member of `std:xml`, generated from the source of OSL.

### Functions

#### `xml.isValid(string text) boolean`

isValid reports whether xml.parse would accept text.

#### `xml.parse(string text) result<*xml.Document, error>`

parse reads an XML document from text. Query it with doc.find, doc.text
and doc.attr using paths such as "feed>entry>title": each name matches an
element at any depth below the previous one. Fails with ParseError,
naming the line, for malformed XML.

#### `xml.parseFile(string path) result<*xml.Document, error>`

parseFile reads and parses the XML file at path like xml.parse. Fails
with NotFoundError, PermissionError or IOError when the file can't be
read and ParseError when it isn't valid XML.

### `*xml.Document`

#### `document.attr(string path, string name) string?`

attr returns the attribute name of the first element matching path, or
null when nothing matches or the element has no such attribute.

#### `document.clear(string path) result<unit, error>`

clear deletes the text and children of the first element matching path,
in place, keeping the element and its attributes. Fails with
NotFoundError when nothing matches.

#### `document.count(string path) int`

count returns how many child elements the first element matching path
has, or 0 when nothing matches. To count the elements matching a path,
use doc.findAll(path).len.

#### `document.find(string path) {attrs: string[string], children: object[], name: string, text: string}?`

find returns the first element matching path as a record {name, text,
attrs, children}, or null when nothing matches. text is the element's
own text, joined and trimmed; attrs maps attribute names, prefixes
included ("xlink:href"), to values; children are records too.

#### `document.findAll(string path) {attrs: string[string], children: object[], name: string, text: string}[]`

findAll returns every element matching path as records (see doc.find),
in document order; [] when nothing matches.

#### `document.get(any path) object`

**Deprecated.** Deprecated: use doc.find(path) instead.

get returns the first element matching path as an object, or null.

#### `document.getAll(any path) array`

**Deprecated.** Deprecated: use doc.findAll(path) instead.

getAll returns every element matching path as objects.

#### `document.getAttr(any path, any attr) any`

**Deprecated.** Deprecated: use doc.attr(path, name) instead.

getAttr returns an attribute of the first element matching path, or
null.

#### `document.getText(any path) any`

**Deprecated.** Deprecated: use doc.text(path) instead.

getText returns the text of the first element matching path, or null.

#### `document.has(string path) boolean`

has reports whether any element matches path.

#### `document.hasAttr(string path, string name) boolean`

hasAttr reports whether the first element matching path has the
attribute name.

#### `document.remove(string path) result<unit, error>`

remove deletes the first element matching path, and its children, in
place; text around it stays. Fails with NotFoundError when nothing
matches.

#### `document.root() {attrs: string[string], children: object[], name: string, text: string}?`

root returns the document's root element as a record with its whole
tree (see doc.find), or null for an empty document.

#### `document.setAttr(string path, string name, any value) result<unit, error>`

setAttr sets the attribute name of the first element matching path, in
place. Fails with NotFoundError when nothing matches.

#### `document.setText(string path, any value) result<unit, error>`

setText replaces the text of the first element matching path, in place,
keeping its child elements. Fails with NotFoundError when nothing
matches.

#### `document.text(string path) string?`

text returns the own text of the first element matching path, joined and
trimmed, or null when nothing matches.

#### `document.toArr() array`

**Deprecated.** Deprecated: use doc.root() instead.

toArr lists the top-level elements as objects.

#### `document.toStr() string`

toStr writes the document as XML text. Attributes are written in sorted
order and text is escaped; an empty document gives "".
