oxml-cli 0.0.9

Command-line XML querying, validation and formatting, powered by oxml
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
<!-- SPDX-License-Identifier: Apache-2.0 OR MIT -->

<h1 align="center">oxml-cli</h1>

<p align="center">
  Query, validate and inspect XML from a shell — powered by
  <a href="https://github.com/sebastienrousseau/oxml">oxml</a>, with zero
  <code>unsafe</code> code.
</p>

<p align="center">
  <a href="https://github.com/sebastienrousseau/oxml-cli/actions"><img src="https://img.shields.io/github/actions/workflow/status/sebastienrousseau/oxml-cli/ci.yml?style=for-the-badge&logo=github" alt="Build" /></a>
  <a href="https://crates.io/crates/oxml-cli"><img src="https://img.shields.io/crates/v/oxml-cli.svg?style=for-the-badge&color=fc8d62&logo=rust" alt="Crates.io" /></a>
  <a href="https://docs.rs/oxml-cli"><img src="https://img.shields.io/badge/docs.rs-oxml--cli-66c2a5?style=for-the-badge&labelColor=555555&logo=docs.rs" alt="Docs.rs" /></a>
  <a href="https://lib.rs/crates/oxml-cli"><img src="https://img.shields.io/badge/lib.rs-oxml--cli-orange.svg?style=for-the-badge" alt="lib.rs" /></a>
  <a href="https://scorecard.dev/viewer/?uri=github.com/sebastienrousseau/oxml-cli"><img src="https://img.shields.io/ossf-scorecard/github.com/sebastienrousseau/oxml-cli?style=for-the-badge&label=OpenSSF%20Scorecard&logo=openssf" alt="OpenSSF Scorecard" /></a>
  <a href="https://www.bestpractices.dev/projects/14311"><img src="https://img.shields.io/cii/level/14311?style=for-the-badge&label=OpenSSF%20Best%20Practices&logo=openssf" alt="OpenSSF Best Practices" /></a>
</p>

---

## Contents

**Getting started**

- [Install]#install — Cargo, from source
- [Quick Start]#quick-start — a query in one line

**The oxml ecosystem**

- [The oxml ecosystem]#the-oxml-ecosystem — six crates, one version

**Reference**

- [Commands]#commands — `query`, `validate`, `stats`, `check`
- [Options]#options
- [Exit status]#exit-status — and why it matters in a pipeline
- [Reading from standard input]#reading-from-standard-input
- [Migration]#migration — from `xmllint`, `xq`, `xmlstarlet`
- [Why this approach?]#why-this-approach
- [Capabilities in 0.0.8]#capabilities-in-008
- [Ecosystem comparison]#ecosystem-comparison
- [Benchmarks]#benchmarks

**Practical**

- [Examples]#examples — recipes that do real work
- [Configuration]#configuration
- [When not to use oxml-cli]#when-not-to-use-oxml-cli
- [FAQ]#faq
- [Development]#development
- [Security]#security
- [Documentation]#documentation
- [Acknowledgements]#acknowledgements
- [License]#license

---

## Install

```bash
cargo install oxml-cli
```

From source:

```bash
git clone https://github.com/sebastienrousseau/oxml-cli
cd oxml-cli
cargo install --path .
```

The binary is called `oxml`.

There are no C dependencies and no build toolchain requirements. If
`cargo` works, this installs.

## Quick Start

```bash
$ oxml query -t '//title' catalogue.xml
Dune
Germinal
```

```bash
$ oxml check catalogue.xml
well-formed (21 nodes)
```

```bash
$ oxml stats catalogue.xml
nodes       21
elements    7
attributes  4
text        8
comments    1
max depth   4
```

## The oxml ecosystem

| Crate | What it is |
|---|---|
| [`oxml`]https://github.com/sebastienrousseau/oxml | The library: parser, tree, XPath 1.0 |
| [`xmlschema`]https://github.com/sebastienrousseau/xmlschema | XSD validation |
| **`oxml-cli`** | **This crate — the command line** |
| [`oxml-wasm`]https://github.com/sebastienrousseau/oxml-wasm | WebAssembly bindings |
| [`oxml-mcp`]https://github.com/sebastienrousseau/oxml-mcp | Model Context Protocol server |
| [`oxml-lsp`]https://github.com/sebastienrousseau/oxml-lsp | Language Server Protocol server |

All six ship one version number, moving in steps of 0.0.1. `oxml-cli
0.0.8` goes with `oxml 0.0.8`, and no other combination is supported.

## Commands

### `query <XPATH> [FILE]`

Evaluate an XPath 1.0 expression.

```bash
$ oxml query '//title' catalogue.xml
<title>Dune</title>
<title>Germinal</title>
```

By default each match is printed as a summary of the node. `-t` prints
the text instead, and `-c` prints only how many matched.

Non-node results — numbers, strings, booleans — are printed directly:

```bash
$ oxml query 'count(//book)' catalogue.xml
2
$ oxml query 'sum(//price)' catalogue.xml
17.49
```

### `validate <XSD> [FILE]`

Validate against an XML Schema.

```bash
$ oxml validate schema.xsd catalogue.xml
valid
```

Exit status 0 means the document conforms, 1 means it does not. The
schema comes first because the document is the argument that defaults
to standard input.

### `stats [FILE]`

Summarise the document: node counts by kind, and maximum depth.

Useful for finding out what is actually in a file before writing a
query against it, and for spotting a document that is deeper or larger
than you expected.

### `check [FILE]`

Report whether the document is well-formed.

```bash
$ oxml check broken.xml
not well-formed at 1:12: at byte 11: input ended unexpectedly
```

The position is a line and column, counted in characters, so it points
where an editor would.

## Options

| Option | Effect |
|---|---|
| `-t`, `--text` | Print matched nodes' text rather than a summary |
| `-c`, `--count` | Print only the number of matches |
| `-n`, `--ns P=URI` | Bind a namespace prefix for `query`; repeatable |
| `-h`, `--help` | Usage |
| `-V`, `--version` | Version |

`-t` and `-c` apply to `query`. Both accept the flag anywhere in the
argument list.

## Exit status

| Code | Meaning |
|---|---|
| 0 | Success; for `validate`, the document conforms |
| 1 | The document is invalid, or no nodes matched |
| 2 | A usage or I/O error |

The distinction between 1 and 2 is the point. A shell script that tests
`if oxml query ... ; then` wants to know "did anything match", and it
must not confuse that with "you passed a path that does not exist". A
tool that returns 1 for both makes `set -e` a liability.

```bash
if oxml query -c '//error' build.xml > /dev/null; then
  echo "the build reported errors"
fi
```

## Reading from standard input

`FILE` defaults to standard input, so `oxml` composes:

```bash
curl -s https://example.com/feed.xml | oxml query -t '//item/title'
```

```bash
unzip -p document.docx word/document.xml | oxml query -c '//w:p'
```

```bash
find . -name '*.xml' -exec oxml check {} \;
```

## Migration

### From `xmllint`

| `xmllint` | `oxml` |
|---|---|
| `xmllint --xpath '//title' f.xml` | `oxml query '//title' f.xml` |
| `xmllint --noout f.xml` | `oxml check f.xml` |
| `xmllint --schema s.xsd f.xml` | `oxml validate s.xsd f.xml` |
| `xmllint --format f.xml` | not available |
| `xmllint --xinclude` | not available |

`xmllint` exits 0 when `--xpath` matches nothing and prints an error to
stderr; `oxml` exits 1, which is usually what a script wants.

### From `xq` / `xmlstarlet`

| `xmlstarlet` | `oxml` |
|---|---|
| `xmlstarlet sel -t -v '//title' f.xml` | `oxml query -t '//title' f.xml` |
| `xmlstarlet val f.xml` | `oxml check f.xml` |
| `xmlstarlet val -s s.xsd f.xml` | `oxml validate s.xsd f.xml` |
| `xmlstarlet ed …` | not available — `oxml` does not write |

`xmlstarlet`'s template language is more capable and considerably
larger. `oxml query` is one expression and one output mode.

## Why this approach?

**One binary, no dependencies.** `xmllint` means libxml2, which means a
package manager, a shared library and a CVE stream. `oxml` is a static
binary produced by `cargo install`.

**Exit codes that mean something.** See [Exit status](#exit-status).

**Memory safety.** The parser underneath forbids `unsafe`, and never
fetches external entities — so `oxml` cannot be made to read
`/etc/passwd` by a document that asks it to. For a tool routinely
pointed at files from elsewhere, that matters more than throughput.

**Predictable output.** One match per line, nothing else on stdout.
Diagnostics go to stderr. It is meant to be piped.

## Capabilities in 0.0.8

- XPath 1.0 queries: ten axes, 25 functions, all four value types
- Text-only and count-only output modes
- XSD validation via `xmlschema`
- Well-formedness checking with line and column
- Document statistics by node kind, and maximum depth
- Standard input, so it composes with pipes
- Exit codes distinguishing "no match" from "usage error"
- UTF-8, UTF-16 and ISO-8859-1 input
- Namespace prefixes bound on the command line with `-n, --ns`

**Not yet:** formatting or pretty-printing, editing, XInclude, XSLT.

## Ecosystem comparison

| | `oxml` | `xmllint` | `xmlstarlet` | `xq` |
|---|---|---|---|---|
| XPath 1.0 | Yes | Yes | Yes | Yes |
| XSD validation | Yes | Yes | Yes | No |
| Editing | No | No | Yes | No |
| Formatting | No | Yes | Yes | Yes |
| XSLT | No | Yes | Yes | No |
| Single static binary | Yes | No | No | Depends |
| C dependency | None | libxml2 | libxml2 | Varies |
| Fetches external entities | **Never** | Configurable | Configurable | Varies |

## Benchmarks

```bash
cargo bench --bench commands
```

Per invocation, with process spawn excluded — spawn is the operating
system's business and swamps everything else on a small document.
Argument handling and file I/O add roughly 9% over the bare parse on a
200 KB file; `--help` costs a fraction of a microsecond.

No absolute throughput figure is published, and that is deliberate: the
same benchmark binary in this suite measured 14.7 and 123.1 MB/s on one
machine on one day, at a load average above 30. A figure without its
conditions is not a measurement, so the benchmark reports paired
comparisons instead. See
[oxml's BENCHMARKS.md](https://github.com/sebastienrousseau/oxml/blob/main/doc/BENCHMARKS.md).

## Examples

Runnable scripts live in [`examples/`](examples/), each with the sample
document it operates on. They run in CI, so they cannot rot.

| Example | What it shows |
|---|---|
| [`query-basics.sh`]examples/query-basics.sh | Every output mode of `query` |
| [`pipeline.sh`]examples/pipeline.sh | Standard input, exit codes, composing with other tools |
| [`inspect.sh`]examples/inspect.sh | `stats` and `check`, including a malformed document |
| [`validate.sh`]examples/validate.sh | XSD validation, valid and invalid |

## Configuration

There is none. No config file, no environment variables, no dotfile.

That is a decision rather than an omission: a command whose behaviour
depends on a file somewhere in your home directory behaves differently
in CI than on your laptop, and the difference surfaces as a confusing
failure. Everything `oxml` does is on the command line.

Resource limits use `oxml`'s defaults. A future release will expose
`--strict` for documents from untrusted sources.

## When not to use oxml-cli

- **You need to edit XML.** `xmlstarlet ed` does; this does not.
- **You need to format or pretty-print.** `xmllint --format` does.
- **You need XSLT.** `xsltproc`.
- **You need XPath 2.0 or 3.1.** This is 1.0, so no `matches()`, no
  `for`, no sequences.
- **The document is larger than memory.** The whole tree is built.

## FAQ

### Why is the binary called `oxml` and the crate `oxml-cli`?

Because `oxml` on crates.io is the library. The crate has to be
`oxml-cli`; the thing you type should be short.

### Why does a query that matches nothing exit 1?

So a shell script can test it. `if oxml query -c '//error' log.xml`
reads naturally and does the right thing. Exit 2 is reserved for "you
made a mistake", which is a different situation and needs a different
response.

### Can it read gzipped XML?

Not directly. Pipe it: `gunzip -c f.xml.gz | oxml query …`.

### How do I query a document with namespaces?

Bind the prefix on the command line:

```bash
oxml query -n m=urn:example:meta -t '//m:item' catalogue.xml
```

The binding lives with the *query*, not the document, so the same
command works against a document that spells the prefix differently —
only the URI has to match. `-n` is repeatable, and later bindings for
the same prefix win.

**An unbound prefix is an error**, not a silent match on the local
part:

```
$ oxml query -t '//m:item' catalogue.xml
oxml: bad XPath: unbound namespace prefix `m`; bind it with --ns m=URI
```

An **unprefixed** name test matches only nodes in no namespace, which
is what XPath 1.0 specifies. If your document declares a default
namespace, `//item` matches nothing — bind a prefix to that URI.

`namespace-uri()` still works and needs no binding, which is useful
when you know the URI and not the prefix:

```bash
oxml query -t "//*[namespace-uri()='urn:example' and local-name()='item']" f.xml
```

### Is the output stable enough to parse?

`query -t` and `query -c` are: one result per line, nothing else on
stdout. `stats` and the default `query` output are human-facing and may
change.

### Does it fetch anything over the network?

No. There is no network code anywhere in the dependency tree, and
external entities are never dereferenced — a document containing
`<!ENTITY xxe SYSTEM "file:///etc/passwd">` cannot make it read that
file. See
[oxml's SECURITY-MODEL.md](https://github.com/sebastienrousseau/oxml/blob/main/doc/SECURITY-MODEL.md).

### Why no `--format`?

Because formatting requires serialisation, and the library reads but
does not write. Adding it means adding a writer, which is a larger
piece of work than it appears — round-tripping comments, entity
references, attribute order and whitespace correctly is most of the
difficulty.

### What happens on a document that is not well-formed?

It is rejected, with a line and column. There is no recovery mode. A
parser that guesses at what a malformed document meant produces a tree
that no two tools agree on.

### Can I use it in CI?

That is the intended use.

```yaml
- run: oxml check config.xml
- run: oxml validate schema.xsd config.xml
```

Both exit non-zero on failure, which is all a CI step needs.

### Does it work on Windows?

Yes. CI builds and tests on Linux, macOS and Windows.

## Development

```bash
./scripts/gate.sh
```

That runs everything CI runs, in the order that fails fastest: format,
clippy, tests, rustdoc, the `#![forbid(unsafe_code)]` check, the
examples, the 95% coverage floor and an MSRV build. It pins the
toolchain rather than trusting `rust-toolchain.toml`, because a
`RUSTUP_TOOLCHAIN` in the environment silently overrides that file and
a lint that exists in one release and not another then makes a green
local run and a red CI one.

The individual steps, if you want them one at a time:

```bash
cargo test --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all --check
cargo bench --bench commands
OXML="$PWD/target/release/oxml" ./examples/run-all.sh
```

CI runs the same set on Linux, macOS and Windows.

## Security

The parser underneath never dereferences external entities, so XXE is
foreclosed structurally rather than by a default. Entity expansion is
bounded. `#![forbid(unsafe_code)]`.

To report a vulnerability, see [SECURITY.md](SECURITY.md). Please do
not open a public issue.

## Documentation

- [doc/]doc/ — the decisions, in longer form
- [oxml's documentation]https://github.com/sebastienrousseau/oxml/tree/main/doc
- [CHANGELOG.md]CHANGELOG.md
- [CONTRIBUTING.md]CONTRIBUTING.md

## Acknowledgements

- **[`xmllint`]https://gitlab.gnome.org/GNOME/libxml2** — the tool
  this is measured against, and the reason the exit-code behaviour is
  what it is.
- **[`xmlstarlet`]https://xmlstar.sourceforge.net/** — for showing
  how much a command-line XML tool can do.
- **[`jq`]https://jqlang.github.io/jq/** — for the standard a
  pipeline-friendly query tool is held to.

## License

Licensed under either of

- Apache License, Version 2.0 ([LICENSE-APACHE]LICENSE-APACHE)
- MIT license ([LICENSE-MIT]LICENSE-MIT)

at your option.