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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
- name: Setup Zig
uses: mlugg/setup-zig@v2
with:
version: 0.16.0
version: 0.17.0

- name: Zig fmt check
shell: bash
Expand All @@ -36,7 +36,7 @@ jobs:
- name: Benchmark smoke
shell: bash
run: |
zig build bench-only -Doptimize=ReleaseFast
zig build bench-only -Doptimize=fast
for mode in validated validated-trusted permissive; do
./zig-out/bin/zxml-bench parse "$mode" bench/smoke.xml 1 >/dev/null
./zig-out/bin/zxml-stream-bench parse "$mode" bench/smoke.xml 1 >/dev/null
Expand All @@ -45,8 +45,8 @@ jobs:
- name: Darwin compile and 32-bit public API
shell: bash
run: |
zig build bench-only -Dtarget=x86_64-macos -Doptimize=ReleaseFast
zig build test-public-api -Dtarget=x86-linux-musl -Dintlen=u64 -Doptimize=ReleaseSafe
zig build bench-only -Dtarget=x86_64-macos -Doptimize=fast
zig build test-public-api -Dtarget=x86-linux-musl -Dintlen=u64 -Doptimize=safe

- name: Conformance
run: zig build conformance
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Prerequisites

- Zig `0.16.0`
- Zig `0.17.0`
- Git

## Local Development Workflow
Expand All @@ -23,6 +23,8 @@ zig build bench-compare
zig build conformance
```

Use `zig build test -j1 -Dtest-jobs=1` to bound both compilation and test-process concurrency.

## Style

- Format Zig code before pushing:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Fast XML parsing for Zig with comptime-generated DOM and streaming types.

The default parser is destructive and permissive. Attributes and text stay source-backed and are materialized lazily; the DOM does not build an attribute array or a separate open-element stack.

Requires Zig `0.17.0`.

## Quick start

```zig
Expand Down
6 changes: 3 additions & 3 deletions bench/runners/run_parse.zig
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ fn runStreamValidated(io: std.Io, alloc: std.mem.Allocator, input: []const u8, i

fn onNode(self: *@This(), node: *const StreamingEventType) bool {
self.count +%= 1;
self.checksum +%= @as(u64, @intFromEnum(node.kind)) +% 1;
self.checksum +%= @as(u64, @backingInt(node.kind)) +% 1;
switch (node.kind) {
.element => {
self.checksum +%= @as(u64, node.name.end - node.name.start);
Expand Down Expand Up @@ -143,7 +143,7 @@ fn runStreamValidatedTrusted(io: std.Io, alloc: std.mem.Allocator, input: []cons

fn onNode(self: *@This(), node: *const StreamingEventType) bool {
self.count +%= 1;
self.checksum +%= @as(u64, @intFromEnum(node.kind)) +% 1;
self.checksum +%= @as(u64, @backingInt(node.kind)) +% 1;
switch (node.kind) {
.element => {
self.checksum +%= @as(u64, node.name.end - node.name.start);
Expand Down Expand Up @@ -175,7 +175,7 @@ fn runStreamPermissive(io: std.Io, alloc: std.mem.Allocator, input: []const u8,

fn onNode(self: *@This(), node: *const StreamingEventType) bool {
self.count +%= 1;
self.checksum +%= @as(u64, @intFromEnum(node.kind)) +% 1;
self.checksum +%= @as(u64, @backingInt(node.kind)) +% 1;
switch (node.kind) {
.element => {
self.checksum +%= @as(u64, node.name.end - node.name.start);
Expand Down
22 changes: 14 additions & 8 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const IntLen = enum {
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const test_jobs = b.option(usize, "test-jobs", "Maximum concurrent test processes");
const intlen = b.option(IntLen, "intlen", "Integer width used for DOM spans and node indexes") orelse .u32;

const config_options = b.addOptions();
Expand Down Expand Up @@ -117,20 +118,19 @@ pub fn build(b: *std.Build) void {
docs_check_cmd.step.dependOn(b.getInstallStep());
examples_check_cmd.step.dependOn(b.getInstallStep());

if (b.args) |args| {
tools_cmd.addArgs(args);
compare_cmd.addArgs(args);
interleaved_cmd.addArgs(args);
conformance_cmd.addArgs(args);
docs_check_cmd.addArgs(args);
examples_check_cmd.addArgs(args);
}
tools_cmd.addPassthruArgs();
compare_cmd.addPassthruArgs();
interleaved_cmd.addPassthruArgs();
conformance_cmd.addPassthruArgs();
docs_check_cmd.addPassthruArgs();
examples_check_cmd.addPassthruArgs();

const mod_tests = b.addTest(.{
.root_module = mod,
.test_runner = .{ .path = b.path("test_runner.zig"), .mode = .simple },
});
const run_mod_tests = b.addRunArtifact(mod_tests);
if (test_jobs) |jobs| run_mod_tests.addArgs(&.{ "--jobs", b.fmt("{d}", .{jobs}) });

const example_tests = b.addTest(.{
.root_module = b.createModule(.{
Expand All @@ -144,6 +144,7 @@ pub fn build(b: *std.Build) void {
.test_runner = .{ .path = b.path("test_runner.zig"), .mode = .simple },
});
const run_example_tests = b.addRunArtifact(example_tests);
if (test_jobs) |jobs| run_example_tests.addArgs(&.{ "--jobs", b.fmt("{d}", .{jobs}) });

const bench_tests = b.addTest(.{
.root_module = b.createModule(.{
Expand All @@ -157,6 +158,7 @@ pub fn build(b: *std.Build) void {
.test_runner = .{ .path = b.path("test_runner.zig"), .mode = .simple },
});
const run_bench_tests = b.addRunArtifact(bench_tests);
if (test_jobs) |jobs| run_bench_tests.addArgs(&.{ "--jobs", b.fmt("{d}", .{jobs}) });

const tools_tests = b.addTest(.{
.root_module = b.createModule(.{
Expand All @@ -170,6 +172,7 @@ pub fn build(b: *std.Build) void {
.test_runner = .{ .path = b.path("test_runner.zig"), .mode = .simple },
});
const run_tools_tests = b.addRunArtifact(tools_tests);
if (test_jobs) |jobs| run_tools_tests.addArgs(&.{ "--jobs", b.fmt("{d}", .{jobs}) });

const tools_common_tests = b.addTest(.{
.root_module = b.createModule(.{
Expand All @@ -180,6 +183,7 @@ pub fn build(b: *std.Build) void {
.test_runner = .{ .path = b.path("test_runner.zig"), .mode = .simple },
});
const run_tools_common_tests = b.addRunArtifact(tools_common_tests);
if (test_jobs) |jobs| run_tools_common_tests.addArgs(&.{ "--jobs", b.fmt("{d}", .{jobs}) });

const conformance_tests = b.addTest(.{
.root_module = b.createModule(.{
Expand All @@ -193,6 +197,7 @@ pub fn build(b: *std.Build) void {
.test_runner = .{ .path = b.path("test_runner.zig"), .mode = .simple },
});
const run_conformance_tests = b.addRunArtifact(conformance_tests);
if (test_jobs) |jobs| run_conformance_tests.addArgs(&.{ "--jobs", b.fmt("{d}", .{jobs}) });

const public_api_tests = b.addTest(.{
.root_module = b.createModule(.{
Expand All @@ -206,6 +211,7 @@ pub fn build(b: *std.Build) void {
.test_runner = .{ .path = b.path("test_runner.zig"), .mode = .simple },
});
const run_public_api_tests = b.addRunArtifact(public_api_tests);
if (test_jobs) |jobs| run_public_api_tests.addArgs(&.{ "--jobs", b.fmt("{d}", .{jobs}) });
const public_api_step = b.step("test-public-api", "Run exhaustive consumer-facing public API tests");
public_api_step.dependOn(&run_public_api_tests.step);

Expand Down
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
.name = .zxml,
.version = "0.1.0",
.fingerprint = 0xeb739e953d363ad3,
.minimum_zig_version = "0.16.0",
.minimum_zig_version = "0.17.0",
.paths = .{
".gitignore",
"LICENSE",
Expand Down
2 changes: 1 addition & 1 deletion src/attr.zig
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ pub fn materializeAttributes(
}
}

source[write] = @intFromEnum(state);
source[write] = @backingInt(state);
write += 1;
const value_start: usize = @intCast(item.value.start);
std.mem.copyForwards(u8, source[write .. write + value_len], source[value_start .. value_start + value_len]);
Expand Down
19 changes: 13 additions & 6 deletions src/document.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1656,8 +1656,9 @@ fn consumeMixedContent(input: []const u8, i: *usize) ParseError!void {

fn consumeChildrenContent(allocator: std.mem.Allocator, input: []const u8, i: *usize) ParseError!void {
std.debug.assert(input[i.*] == '(');
var stack_fallback = std.heap.stackFallback(512, allocator);
const temp_allocator = stack_fallback.get();
var stack_buffer: [512]u8 align(@alignOf(ContentFrame)) = undefined;
var stack_fallback: std.heap.BufferFirstAllocator = .init(&stack_buffer, allocator);
const temp_allocator = stack_fallback.allocator();
var stack: std.ArrayList(ContentFrame) = .empty;
defer stack.deinit(temp_allocator);

Expand Down Expand Up @@ -2470,15 +2471,15 @@ pub fn GetDocument(comptime options: ParseOptions) type {
const end: usize = @intCast(span.end);
if (end >= self.source.len) return .raw;
return switch (self.source[end]) {
@intFromEnum(TextMaterializationState.decoded) => .decoded,
@backingInt(TextMaterializationState.decoded) => .decoded,
else => .raw,
};
}

inline fn markTextState(self: *Self, idx: IndexInt, state: TextMaterializationState) void {
if (comptime options.non_destructive) return;
const end: usize = @intCast(self.nodes[@intCast(idx)].valueSpan(idx).end);
if (end < self.source.len) self.source[end] = @intFromEnum(state);
if (end < self.source.len) self.source[end] = @backingInt(state);
}

fn materializeText(self: *Self, idx: IndexInt, alloc: std.mem.Allocator) ValueError!common.SliceResult {
Expand Down Expand Up @@ -3253,7 +3254,7 @@ test "XML character vector exits never skip invalid controls or encodings" {
}

test "XML character validation crosses dense Unicode and ASCII windows" {
const source = "é漢😀" ** 20 ++ " ASCII " ** 20 ++ "Ω漢é" ** 35;
const source = &(comptime repeatTestBytes("é漢😀", 20) ++ repeatTestBytes(" ASCII ", 20) ++ repeatTestBytes("Ω漢é", 35));
for (0..source.len + 1) |split| {
var expected = split;
while (!std.unicode.utf8ValidateSlice(source[0..expected])) : (expected -= 1) {}
Expand All @@ -3279,7 +3280,7 @@ test "vector UTF-8 validation rejects illegal sequences at every lane boundary"
try std.testing.expectError(error.InvalidXmlCharacter, xmlValidPrefixLenStreaming(&input));
}
}
const valid = "é漢😀Ω" ** 12 ++ " \t\n\rASCII" ** 4;
const valid = &(comptime repeatTestBytes("é漢😀Ω", 12) ++ repeatTestBytes(" \t\n\rASCII", 4));
for (0..valid.len + 1) |end| {
var boundary = end;
while (!std.unicode.utf8ValidateSlice(valid[0..boundary])) : (boundary -= 1) {}
Expand Down Expand Up @@ -3441,3 +3442,9 @@ test "compact validated serialization preserves a required DOCTYPE" {
try std.testing.expectEqualStrings("&e;", reparsed.nodeAt(1).?.getAttributeValueRaw("a").?);
}
}

fn repeatTestBytes(comptime bytes: []const u8, comptime count: usize) [bytes.len * count]u8 {
var result: [bytes.len * count]u8 = undefined;
for (0..count) |i| @memcpy(result[i * bytes.len ..][0..bytes.len], bytes);
return result;
}
7 changes: 6 additions & 1 deletion src/parser.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2092,7 +2092,12 @@ test "inline node storage spills safely and releases every failed allocation" {
const Check = struct {
fn run(allocator: std.mem.Allocator) !void {
const options: ParseOptions = .{ .validate_well_formedness = true };
var source = ("<r>" ++ "<a>value</a>" ** 90 ++ "</r>").*;
var source = comptime blk: {
const child = "<a>value</a>";
var children: [child.len * 90]u8 = undefined;
for (0..90) |i| @memcpy(children[i * child.len ..][0..child.len], child);
break :blk "<r>".* ++ children ++ "</r>".*;
};
var doc = try options.parse(allocator, &source);
defer doc.deinit();
try std.testing.expectEqual(@as(usize, 182), doc.nodes.len);
Expand Down
4 changes: 2 additions & 2 deletions src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1564,11 +1564,11 @@ fn refAllDeclsRecursiveSeen(comptime T: type, comptime seen: []const type) void
// Recurse into namespace-style containers only; value-carrying types blow
// up the traversal without contributing additional declaration coverage.
inline for (comptime std.meta.declarations(T)) |decl| {
const decl_value = @field(T, decl.name);
const decl_value = @field(T, decl);
if (@TypeOf(decl_value) != type) continue;
const Child = decl_value;
if (comptime switch (@typeInfo(Child)) {
.@"struct" => |s| s.fields.len != 0,
.@"struct" => |s| s.field_names.len != 0,
.@"union", .@"enum", .@"opaque" => true,
else => true,
}) continue;
Expand Down
16 changes: 8 additions & 8 deletions src/scanner.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const byte_scan_vector_len: comptime_int = switch (builtin.cpu.arch) {
pub noinline fn countByte(source: []const u8, value: u8) usize {
const Vec = @Vector(byte_scan_vector_len, u8);
const Bits = @Vector(byte_scan_vector_len, u1);
const Mask = std.meta.Int(.unsigned, byte_scan_vector_len);
const Mask = @Int(.unsigned, byte_scan_vector_len);
const needle: Vec = @splat(value);
var i: usize = 0;
var count: usize = 0;
Expand Down Expand Up @@ -145,7 +145,7 @@ pub inline fn findTextEnd(noalias haystack: []const u8, start: usize) ?usize {
const Vec = @Vector(byte_scan_vector_len, u8);
if (haystack.len - start >= @sizeOf(Vec)) {
const Bits = @Vector(byte_scan_vector_len, u1);
const Mask = std.meta.Int(.unsigned, byte_scan_vector_len);
const Mask = @Int(.unsigned, byte_scan_vector_len);
// Load the vector directly; array coercion can reconstruct it from scalar loads.
const bytes = @as(*align(1) const Vec, @ptrCast(haystack.ptr + start)).*;
const lt_vec: Vec = @splat('<');
Expand All @@ -170,7 +170,7 @@ pub noinline fn scanStartTagEnd(noalias input: []const u8, start: usize) ?StartT

const Vec = @Vector(byte_scan_vector_len, u8);
const Bits = @Vector(byte_scan_vector_len, u1);
const Mask = std.meta.Int(.unsigned, byte_scan_vector_len);
const Mask = @Int(.unsigned, byte_scan_vector_len);
const gt_vec: Vec = @splat('>');
const sq_vec: Vec = @splat('\'');
const dq_vec: Vec = @splat('"');
Expand Down Expand Up @@ -250,7 +250,7 @@ pub noinline fn scanStartTagEndFast(noalias input: []const u8, start: usize) usi
if (start >= input.len) return 0;
const Vec = @Vector(byte_scan_vector_len, u8);
const Bits = @Vector(byte_scan_vector_len, u1);
const Mask = std.meta.Int(.unsigned, byte_scan_vector_len);
const Mask = @Int(.unsigned, byte_scan_vector_len);
if (input.len - start < @sizeOf(Vec)) return scanStartTagEndFastLong(input, start, start, 0, false);

const bytes: Vec = input[start..][0..@sizeOf(Vec)].*;
Expand All @@ -277,7 +277,7 @@ noinline fn scanStartTagEndFastLong(
) usize {
const Vec = @Vector(byte_scan_vector_len, u8);
const Bits = @Vector(byte_scan_vector_len, u1);
const Mask = std.meta.Int(.unsigned, byte_scan_vector_len);
const Mask = @Int(.unsigned, byte_scan_vector_len);
var seen_quotes = initial_seen_quotes;
var odd = initial_odd;
var i = initial_i;
Expand Down Expand Up @@ -490,7 +490,7 @@ pub inline fn scanQuotedValueSpecials(noalias hay: []const u8, start: usize, quo

const Vec = @Vector(byte_scan_vector_len, u8);
const Bits = @Vector(byte_scan_vector_len, u1);
const Mask = std.meta.Int(.unsigned, byte_scan_vector_len);
const Mask = @Int(.unsigned, byte_scan_vector_len);
const quote_vec: Vec = @splat(quote);
const lt_vec: Vec = @splat('<');
const amp_vec: Vec = @splat('&');
Expand Down Expand Up @@ -730,7 +730,7 @@ pub inline fn scanTextSpecials(noalias hay: []const u8, start: usize) TextSpecia

const Vec = @Vector(byte_scan_vector_len, u8);
const Bits = @Vector(byte_scan_vector_len, u1);
const Mask = std.meta.Int(.unsigned, byte_scan_vector_len);
const Mask = @Int(.unsigned, byte_scan_vector_len);
const lt_vec: Vec = @splat('<');
const close_vec: Vec = @splat(']');
const amp_vec: Vec = @splat('&');
Expand Down Expand Up @@ -1110,7 +1110,7 @@ test "bytePairPresence finds either sentinel in short and vector-sized inputs" {
try std.testing.expect(both.first);
try std.testing.expect(both.second);

var long = [_]u8{'x'} ** (byte_scan_vector_len * 2 + 3);
var long: [byte_scan_vector_len * 2 + 3]u8 = @splat('x');
long[byte_scan_vector_len - 1] = ']';
long[byte_scan_vector_len * 2 + 1] = '&';
const vector = bytePairPresence(&long, ']', '&');
Expand Down
6 changes: 3 additions & 3 deletions src/streaming.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1651,13 +1651,13 @@ const CallbackKind = enum {

fn callbackKind(comptime callback: anytype, comptime Node: type) CallbackKind {
const cb_info = @typeInfo(@TypeOf(callback)).@"fn";
return switch (cb_info.params.len) {
1 => switch (cb_info.params[0].type.?) {
return switch (cb_info.param_types.len) {
1 => switch (cb_info.param_types[0].?) {
Node => .node,
*const Node => .node_ptr,
else => @compileError("streaming callback node parameter must be Node or *const Node"),
},
2 => switch (cb_info.params[1].type.?) {
2 => switch (cb_info.param_types[1].?) {
Node => .ctx_node,
*const Node => .ctx_node_ptr,
else => @compileError("streaming callback node parameter must be Node or *const Node"),
Expand Down
8 changes: 4 additions & 4 deletions src/tables.zig
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const std = @import("std");

pub const WhitespaceTable = blk: {
var t = [_]bool{false} ** 256;
var t: [256]bool = @splat(false);
t[' '] = true;
t['\t'] = true;
t['\n'] = true;
Expand All @@ -10,7 +10,7 @@ pub const WhitespaceTable = blk: {
};

pub const NameStartTable = blk: {
var t = [_]bool{false} ** 256;
var t: [256]bool = @splat(false);
for ('A'..('Z' + 1)) |c| t[c] = true;
for ('a'..('z' + 1)) |c| t[c] = true;
for (0x80..256) |c| t[c] = true;
Expand All @@ -20,7 +20,7 @@ pub const NameStartTable = blk: {
};

pub const NameCharTable = blk: {
var t = [_]bool{false} ** 256;
var t: [256]bool = @splat(false);
for ('A'..('Z' + 1)) |c| t[c] = true;
for ('a'..('z' + 1)) |c| t[c] = true;
for ('0'..('9' + 1)) |c| t[c] = true;
Expand All @@ -33,7 +33,7 @@ pub const NameCharTable = blk: {
};

pub const AttrUnquotedValueCharTable = blk: {
var t = [_]bool{true} ** 256;
var t: [256]bool = @splat(true);
t['<'] = false;
t['>'] = false;
t['&'] = false;
Expand Down
Loading
Loading