Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ jobs:
- name: Unit tests — XML pull parser
run: .venv/bin/mojo run -I src test/test_pull.mojo

- name: Unit tests — parse-error positions
run: .venv/bin/mojo run -I src test/test_errors.mojo

- name: Conformance — byte-match vs CPython xml.etree
run: MOJO=.venv/bin/mojo python3 test/anchor_run.py

Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## Unreleased

- New `xml.errors` module (exported from the package): `line_col(source,
offset)` maps a byte offset to a 1-based (line, column) pair — the column
is the 1-based BYTE offset within the line, no UTF-8 decoding — and
`parse_error(msg, source, offset)` builds an `Error` reading
`<msg> at line <L>, column <C>: '<snippet>'`, where the snippet is up to
~30 bytes of the offending line centered on the column,
whitespace-trimmed, with `...` where truncated, and never multi-line.
- Pull-parser errors now carry that position + snippet wherever a byte
offset exists at the raise site: every strict-mode error (previously a
bare `(line L, column C)` suffix with no snippet) and the structural
errors both modes raise — unterminated constructs / start tags /
attributes / attribute values, unquoted attribute values, malformed
start/end tags, and empty element names (previously no position at all).
- No mechanism change: parsers still `raise Error(...)`, no new error
types, and existing `contains=`-style message checks keep matching.

## v0.1.0 — 2026-07-06

First release. General-purpose XML parsing in pure Mojo, mirroring Python's
Expand Down
2 changes: 1 addition & 1 deletion pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ platforms = ["linux-64", "osx-arm64"]
version = "0.1.0"

[tasks]
test = "mojo run -I src test/test_etree.mojo && mojo run -I src test/test_pull.mojo"
test = "mojo run -I src test/test_etree.mojo && mojo run -I src test/test_pull.mojo && mojo run -I src test/test_errors.mojo"
demo = "mojo run -I src examples/catalog.mojo"
bench = "mojo build -I src bench/bench_parse.mojo -o .bench_parse && ./.bench_parse"
fmt = "mojo format src/ test/ examples/ bench/"
Expand Down
4 changes: 4 additions & 0 deletions src/xml/__init__.mojo
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ from xml.etree import (
tostring,
SubElement,
)
from xml.errors import (
line_col,
parse_error,
)
135 changes: 135 additions & 0 deletions src/xml/errors.mojo
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Position-aware parse errors.

`line_col` maps a byte offset in a source buffer to a 1-based
(line, column) pair, and `parse_error` builds an `Error` whose message
carries that position plus a short snippet of the offending line:

<msg> at line <L>, column <C>: '<snippet>'

Positions are byte-based: the column is the 1-based BYTE offset within
the line, not a codepoint or display column. That keeps the computation
deterministic and free of UTF-8 decode cost; for ASCII-heavy markup the
byte column and the visual column coincide.

This module is the error-reporting pattern shared across the mojo-*
parser suite.
"""

comptime _LF = UInt8(0x0A)
comptime _CR = UInt8(0x0D)

# Snippet size budget, in bytes, before the `...` truncation markers are
# added. Wide enough to show meaningful context, narrow enough that error
# messages stay one readable line.
comptime _SNIPPET_BUDGET = 30


def _is_ws(b: UInt8) -> Bool:
return b == 0x20 or b == 0x09 or b == _CR or b == _LF


def line_col(source: Span[UInt8, _], offset: Int) -> Tuple[Int, Int]:
"""1-based (line, column) of byte `offset` in `source`.

The column is the 1-based BYTE offset within the line — codepoints
are never decoded, so the result is cheap and deterministic even on
invalid UTF-8. Only LF (0x0A) terminates a line: after a CRLF
sequence the next byte is column 1 of the next line, with no phantom
column contributed by the CR. An offset pointing AT an LF reports
the line that newline terminates (column = line length + 1). Offsets
past the end of `source` clamp to the end; an empty source yields
(1, 1).
"""
var limit = offset
if limit > len(source):
limit = len(source)
if limit < 0:
limit = 0
var line = 1
var last_nl = -1
for i in range(limit):
if source[i] == _LF:
line += 1
last_nl = i
return (line, limit - last_nl)


def _snippet(source: Span[UInt8, _], offset: Int) -> String:
"""Up to ~`_SNIPPET_BUDGET` bytes of the line containing `offset`.

The line is trimmed of leading/trailing whitespace (which also drops
the CR of a CRLF line ending), then windowed around the offset with
`...` marking each side that was cut. Window edges are nudged off
UTF-8 continuation bytes so the result is always valid UTF-8. The
result never contains a newline.
"""
var n = len(source)
var anchor = offset
if anchor > n:
anchor = n
if anchor < 0:
anchor = 0
# Line bounds around the anchor; an anchor sitting AT an LF belongs
# to the line that newline terminates.
var line_start = anchor
while line_start > 0 and source[line_start - 1] != _LF:
line_start -= 1
var line_end = anchor
while line_end < n and source[line_end] != _LF:
line_end += 1
# Trim surrounding whitespace.
while line_start < line_end and _is_ws(source[line_start]):
line_start += 1
while line_end > line_start and _is_ws(source[line_end - 1]):
line_end -= 1
var win_start = line_start
var win_end = line_end
var cut_left = False
var cut_right = False
if line_end - line_start > _SNIPPET_BUDGET:
win_start = anchor - _SNIPPET_BUDGET // 2
if win_start > line_end - _SNIPPET_BUDGET:
win_start = line_end - _SNIPPET_BUDGET
if win_start < line_start:
win_start = line_start
win_end = win_start + _SNIPPET_BUDGET
# Never split a multi-byte UTF-8 sequence at a window edge.
while win_start < win_end and (source[win_start] & 0xC0) == 0x80:
win_start += 1
while win_end < line_end and (source[win_end] & 0xC0) == 0x80:
win_end += 1
cut_left = win_start > line_start
cut_right = win_end < line_end
var out = String()
if cut_left:
out += "..."
out += String(StringSlice(unsafe_from_utf8=source[win_start:win_end]))
if cut_right:
out += "..."
return out^


def parse_error(msg: String, source: Span[UInt8, _], offset: Int) -> Error:
"""An `Error` locating `msg` at byte `offset` of `source`.

The message is exactly:

<msg> at line <L>, column <C>: '<snippet>'

where line/column come from `line_col` (1-based; column is a byte
offset within the line) and the snippet is the offending line,
whitespace-trimmed and truncated to ~30 bytes centered on the
column, with `...` where truncated. The message never contains a
newline, so it renders on one line in test output and logs.
"""
var lc = line_col(source, offset)
return Error(
msg
+ " at line "
+ String(lc[0])
+ ", column "
+ String(lc[1])
+ ": '"
+ _snippet(source, offset)
+ "'"
)
77 changes: 37 additions & 40 deletions src/xml/pull.mojo
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ CPython's `xml.etree` for self-contained documents; parameter entities
access — no XXE surface).
"""

from xml.errors import parse_error

comptime EVENT_START = 0
comptime EVENT_END = 1
comptime EVENT_TEXT = 2
Expand Down Expand Up @@ -458,10 +460,13 @@ struct XmlPullParser(Copyable, Movable):
unknown entities, invalid element/attribute names, valueless or
duplicate attributes, a raw `<` in an attribute value, a literal `]]>`
in character data, `--` inside a comment, and out-of-Char-production
character references all raise with a line/column location instead of
being liberally recovered. Useful for debugging a feed you produce;
leave it off for feeds you merely consume (liberal mode stays
deliberately tolerant of these).
character references all raise instead of being liberally recovered.
Useful for debugging a feed you produce; leave it off for feeds you
merely consume (liberal mode stays deliberately tolerant of these).

Errors — strict-mode and the structural ones both modes raise — carry
a `line L, column C` location plus a snippet of the offending line
(see `xml.errors.parse_error`).
"""

var src: String
Expand All @@ -481,30 +486,14 @@ struct XmlPullParser(Copyable, Movable):
self._open = List[String]()
self._entities = Dict[String, String]()

def _location(self, p: Int) -> String:
"""Human-readable "line L, column C" for byte offset `p`.

Computed lazily (only on error paths), so the happy path pays
nothing for location tracking.
"""
var bytes = self.src.as_bytes()
var line = 1
var col = 1
var limit = p
if limit > len(bytes):
limit = len(bytes)
for i in range(limit):
if bytes[i] == 0x0A:
line += 1
col = 1
else:
col += 1
return String("line ") + String(line) + ", column " + String(col)

def _strict_error(self, msg: String, p: Int) -> Error:
return Error(
"mojo-xml [strict]: " + msg + " (" + self._location(p) + ")"
)
# Position + snippet are computed lazily (only on error paths),
# so the happy path pays nothing for location tracking.
return parse_error("mojo-xml [strict]: " + msg, self.src.as_bytes(), p)

def _error(self, msg: String, p: Int) -> Error:
"""A positioned parse error (both strict and liberal mode)."""
return parse_error("mojo-xml: " + msg, self.src.as_bytes(), p)

def _len(self) -> Int:
return self.src.byte_length()
Expand All @@ -527,15 +516,20 @@ struct XmlPullParser(Copyable, Movable):
return True

def _find(self, start: Int, literal: StaticString) raises -> Int:
"""Byte offset of `literal` at or after `start`, or raises."""
"""Byte offset of `literal` at or after `start`, or raises.

The error position is `start` — the beginning of the search,
i.e. just inside the construct that was never terminated —
rather than the uninformative end of input.
"""
var i = start
while i < self._len():
if self._starts_with(i, literal):
return i
i += 1
raise Error(
String("mojo-xml: unterminated construct, expected: ")
+ String(literal)
raise self._error(
String("unterminated construct, expected: ") + String(literal),
start,
)

def _skip_space(mut self):
Expand Down Expand Up @@ -717,12 +711,12 @@ struct XmlPullParser(Copyable, Movable):
"invalid character in name '" + name + "'", p
)

def _read_attrs(mut self) raises -> Dict[String, String]:
def _read_attrs(mut self, tag_start: Int) raises -> Dict[String, String]:
var attrs = Dict[String, String]()
while True:
self._skip_space()
if self.pos >= self._len():
raise Error("mojo-xml: unterminated start tag")
raise self._error("unterminated start tag", tag_start)
var b = self._at(self.pos)
if b == _GT or b == _SLASH:
return attrs^
Expand All @@ -739,16 +733,19 @@ struct XmlPullParser(Copyable, Movable):
self.pos += 1
self._skip_space()
if self.pos >= self._len():
raise Error("mojo-xml: unterminated attribute")
raise self._error("unterminated attribute", name_pos)
var quote = self._at(self.pos)
if quote != _SQUOTE and quote != _DQUOTE:
raise Error("mojo-xml: unquoted attribute value")
raise self._error("unquoted attribute value", self.pos)
self.pos += 1
var vstart = self.pos
while self.pos < self._len() and self._at(self.pos) != quote:
self.pos += 1
if self.pos >= self._len():
raise Error("mojo-xml: unterminated attribute value")
# Point at the opening quote that was never closed.
raise self._error(
"unterminated attribute value", vstart - 1
)
if self.strict:
# A raw '<' is never allowed in an attribute value.
for k in range(vstart, self.pos):
Expand Down Expand Up @@ -922,7 +919,7 @@ struct XmlPullParser(Copyable, Movable):
var name = self._read_name()
self._skip_space()
if self.pos >= self._len() or self._at(self.pos) != _GT:
raise Error("mojo-xml: malformed end tag: " + name)
raise self._error("malformed end tag: " + name, tag_start)
self.pos += 1
if self.strict:
if len(self._open) == 0:
Expand All @@ -946,17 +943,17 @@ struct XmlPullParser(Copyable, Movable):
var name_pos = self.pos
var name = self._read_name()
if name.byte_length() == 0:
raise Error("mojo-xml: empty element name")
raise self._error("empty element name", name_pos)
if self.strict:
self._validate_name(name, name_pos)
var attrs = self._read_attrs()
var attrs = self._read_attrs(name_pos - 1)
var self_closing = False
if self._at(self.pos) == _SLASH:
self_closing = True
self.pos += 1
self._skip_space()
if self.pos >= self._len() or self._at(self.pos) != _GT:
raise Error("mojo-xml: malformed start tag: " + name)
raise self._error("malformed start tag: " + name, name_pos)
self.pos += 1
if self_closing:
self._pending_end = name.copy()
Expand Down
Loading
Loading