Skip to content

Commit 4ae76ea

Browse files
cigraingerclaude
andcommitted
Initial release: SIMD-accelerated XML parsing with XPath 1.0 for Elixir
Rustler NIF wrapping the simdxml Rust crate. Features: - Full XPath 1.0 evaluation (all 13 axes, 30+ functions, predicates, unions) - Composable query combinator DSL (SimdXml.Query) - Immutable document/element references with Enumerable protocol - Compiled queries for repeated evaluation across documents - Batch processing with bloom filter prescanning - Quick grep-mode for simple //tagname extraction at memory bandwidth - Precompiled NIFs for 7 targets via rustler_precompiled - 367 tests (82 doctests + 285 unit) including adversarial/stress tests - Livebook-compatible .livemd guides Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
0 parents  commit 4ae76ea

38 files changed

Lines changed: 6613 additions & 0 deletions

.formatter.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Used by "mix format"
2+
[
3+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4+
]

.github/workflows/ci.yml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: CI
2+
on:
3+
push:
4+
branches: [main]
5+
pull_request:
6+
branches: [main]
7+
8+
env:
9+
SIMDXML_BUILD: "1"
10+
11+
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
include:
18+
- elixir: "1.19"
19+
otp: "28"
20+
- elixir: "1.18"
21+
otp: "27"
22+
- elixir: "1.17"
23+
otp: "27"
24+
- elixir: "1.16"
25+
otp: "26"
26+
steps:
27+
- uses: actions/checkout@v4
28+
- uses: erlef/setup-beam@v1
29+
with:
30+
elixir-version: ${{ matrix.elixir }}
31+
otp-version: ${{ matrix.otp }}
32+
- uses: dtolnay/rust-toolchain@stable
33+
- uses: actions/cache@v4
34+
with:
35+
path: |
36+
deps
37+
_build
38+
native/simdxml_nif/target
39+
key: ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('mix.lock', 'native/simdxml_nif/Cargo.lock') }}
40+
- run: mix deps.get
41+
- run: mix compile --warnings-as-errors
42+
- run: mix test
43+
44+
lint:
45+
runs-on: ubuntu-latest
46+
steps:
47+
- uses: actions/checkout@v4
48+
- uses: erlef/setup-beam@v1
49+
with:
50+
elixir-version: "1.19"
51+
otp-version: "28"
52+
- uses: dtolnay/rust-toolchain@stable
53+
with:
54+
components: clippy, rustfmt
55+
- uses: actions/cache@v4
56+
with:
57+
path: |
58+
deps
59+
_build
60+
native/simdxml_nif/target
61+
key: ${{ runner.os }}-lint-${{ hashFiles('mix.lock', 'native/simdxml_nif/Cargo.lock') }}
62+
- run: mix deps.get
63+
- run: mix compile --warnings-as-errors
64+
- run: mix format --check-formatted
65+
- run: cd native/simdxml_nif && cargo fmt -- --check
66+
- run: cd native/simdxml_nif && cargo clippy -- -D warnings

.github/workflows/precompile.yml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: Precompile NIFs
2+
on:
3+
push:
4+
tags:
5+
- "v*"
6+
7+
permissions:
8+
contents: write
9+
10+
jobs:
11+
build_nif:
12+
name: NIF ${{ matrix.nif }} - ${{ matrix.job.target }}
13+
runs-on: ${{ matrix.job.os }}
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
nif: ["2.15", "2.16", "2.17"]
18+
job:
19+
- { target: aarch64-apple-darwin, os: macos-14 }
20+
- { target: x86_64-apple-darwin, os: macos-13 }
21+
- { target: x86_64-unknown-linux-gnu, os: ubuntu-22.04 }
22+
- { target: x86_64-unknown-linux-musl, os: ubuntu-22.04 }
23+
- { target: aarch64-unknown-linux-gnu, os: ubuntu-22.04 }
24+
- { target: aarch64-unknown-linux-musl, os: ubuntu-22.04 }
25+
- { target: x86_64-pc-windows-msvc, os: windows-2022 }
26+
27+
steps:
28+
- uses: actions/checkout@v4
29+
30+
- name: Build NIF
31+
uses: philss/rustler-precompiled-action@v1.1.4
32+
with:
33+
project-name: simdxml_nif
34+
project-version: ${{ github.ref_name }}
35+
target: ${{ matrix.job.target }}
36+
nif-version: ${{ matrix.nif }}
37+
use-cross: ${{ matrix.job.os == 'ubuntu-22.04' && matrix.job.target != 'x86_64-unknown-linux-gnu' }}
38+
project-dir: native/simdxml_nif
39+
40+
- name: Upload artifact
41+
uses: actions/upload-artifact@v4
42+
with:
43+
name: ${{ matrix.job.target }}-nif-${{ matrix.nif }}
44+
path: |
45+
native/simdxml_nif/priv/native/*
46+
47+
release:
48+
needs: build_nif
49+
runs-on: ubuntu-latest
50+
steps:
51+
- uses: actions/checkout@v4
52+
53+
- uses: actions/download-artifact@v4
54+
with:
55+
path: artifacts
56+
merge-multiple: true
57+
58+
- name: Create GitHub Release
59+
uses: softprops/action-gh-release@v2
60+
with:
61+
files: artifacts/**/*
62+
generate_release_notes: true

.gitignore

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# The directory Mix will write compiled artifacts to.
2+
/_build/
3+
4+
# If you run "mix test --cover", coverage assets end up here.
5+
/cover/
6+
7+
# The directory Mix downloads your dependencies sources to.
8+
/deps/
9+
10+
# Where third-party dependencies like ExDoc output generated docs.
11+
/doc/
12+
13+
# Temporary files, for example, from tests.
14+
/tmp/
15+
16+
# If the VM crashes, it generates a dump, let's ignore it too.
17+
erl_crash.dump
18+
19+
# Also ignore archive artifacts (built via "mix archive.build").
20+
*.ez
21+
22+
# Ignore package tarball (built via "mix hex.build").
23+
simdxml-*.tar
24+
25+
# Rust build artifacts
26+
/native/simdxml_nif/target/
27+
28+
# NIF binary (built locally or downloaded)
29+
/priv/native/
30+
31+
# OS
32+
.DS_Store

README.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# SimdXml
2+
3+
SIMD-accelerated XML parsing with full XPath 1.0 support for Elixir.
4+
5+
SimdXml parses XML into a flat structural index (~16 bytes per tag) using SIMD
6+
instructions, then evaluates XPath expressions against it using array operations.
7+
No DOM tree, no atom creation from untrusted input, no XXE vulnerabilities.
8+
9+
Wraps the [simdxml](https://crates.io/crates/simdxml) Rust crate via
10+
[Rustler](https://github.com/rusterlium/rustler) NIFs with precompiled binaries
11+
for all major platforms.
12+
13+
## Installation
14+
15+
```elixir
16+
def deps do
17+
[{:simdxml, "~> 0.1.0"}]
18+
end
19+
```
20+
21+
Precompiled NIF binaries are provided for macOS (Apple Silicon, Intel), Linux
22+
(x86_64, aarch64, musl), and Windows. Set `SIMDXML_BUILD=1` to compile from
23+
source if needed.
24+
25+
## Quick start
26+
27+
```elixir
28+
# Parse
29+
doc = SimdXml.parse!("<library><book lang='en'><title>Elixir</title></book></library>")
30+
31+
# Query with XPath
32+
SimdXml.xpath_text!(doc, "//title")
33+
#=> ["Elixir"]
34+
35+
# Navigate elements (Enumerable)
36+
root = SimdXml.Document.root(doc)
37+
Enum.map(root, & &1.tag)
38+
#=> ["book"]
39+
40+
# Attributes
41+
[book] = SimdXml.Element.children(root)
42+
SimdXml.Element.get(book, "lang")
43+
#=> "en"
44+
```
45+
46+
## Query combinators
47+
48+
Build XPath queries with Elixir pipes instead of strings:
49+
50+
```elixir
51+
import SimdXml.Query
52+
53+
query = descendant("book") |> where_attr("lang", "en") |> child("title") |> text()
54+
55+
SimdXml.query!(doc, query)
56+
#=> ["Elixir"]
57+
58+
# Inspect the generated XPath
59+
SimdXml.Query.to_xpath(query)
60+
#=> "//book[@lang='en']/title/text()"
61+
```
62+
63+
Queries are composable data structures — extract common fragments and reuse them:
64+
65+
```elixir
66+
books = descendant("book")
67+
english = books |> where_attr("lang", "en")
68+
titles = english |> child("title") |> text()
69+
authors = english |> child("author") |> text()
70+
```
71+
72+
## Compiled queries
73+
74+
Compile once, evaluate against many documents:
75+
76+
```elixir
77+
query = SimdXml.compile!("//title")
78+
79+
SimdXml.eval_text!(doc1, query)
80+
SimdXml.eval_text!(doc2, query)
81+
82+
# Optimized short-circuit operations
83+
SimdXml.eval_count!(doc, query) #=> 1
84+
SimdXml.eval_exists?(doc, query) #=> {:ok, true}
85+
```
86+
87+
Compiled queries are NIF resources — safe to share across processes, store in
88+
ETS, or hold in module attributes.
89+
90+
## Batch processing
91+
92+
Process thousands of documents with bloom filter prescanning:
93+
94+
```elixir
95+
query = SimdXml.compile!("//claim")
96+
{:ok, results} = SimdXml.Batch.eval_text_bloom(xml_binaries, query)
97+
```
98+
99+
Documents that cannot contain the target tags are skipped without parsing.
100+
101+
## Quick grep mode
102+
103+
For simple `//tagname` extraction at memory bandwidth — no structural index:
104+
105+
```elixir
106+
scanner = SimdXml.Quick.new("claim")
107+
SimdXml.Quick.extract_first(scanner, xml) #=> "First claim text"
108+
SimdXml.Quick.exists?(scanner, xml) #=> true
109+
SimdXml.Quick.count(scanner, xml) #=> 42
110+
```
111+
112+
## Result helpers
113+
114+
```elixir
115+
SimdXml.Result.one(doc, "//title") #=> "Elixir"
116+
SimdXml.Result.fetch(doc, "//title") #=> {:ok, "Elixir"}
117+
SimdXml.Result.all(doc, "//title") #=> ["Elixir"]
118+
```
119+
120+
## Why SimdXml?
121+
122+
| | SimdXml | SweetXml | Saxy |
123+
|---|---------|----------|------|
124+
| **Parser** | SIMD Rust NIF | xmerl (Erlang) | Pure Elixir SAX |
125+
| **XPath** | Full 1.0 | Full 1.0 (via xmerl) | None |
126+
| **Memory** | ~16 bytes/tag | ~350 bytes/node | Streaming |
127+
| **Atom safety** | Strings only | Creates atoms | Strings only |
128+
| **XXE safe** | No DTD processing | Vulnerable by default | No DTD processing |
129+
| **API** | Combinators + XPath | `~x` sigil | SAX handlers |
130+
| **Batch** | Bloom-filtered | No | No |
131+
132+
## Documentation
133+
134+
Full API docs and interactive Livebook guides:
135+
136+
- [Getting Started](pages/getting-started.livemd)
137+
- [Query Combinators](pages/query-combinators.livemd)
138+
- [Performance Guide](pages/performance.livemd)
139+
140+
## License
141+
142+
MIT

0 commit comments

Comments
 (0)