A reusable DOT-language parser library for Zig. It parses DOT input and exposes its structure without performing layout and without depending on any particular graph engine.
Status: experimental
0.x. Backward compatibility is not promised and breaking changes are expected.
Version 0.3.0: see the changelog.
The grammar grows one narrow end-to-end slice at a time:
strict digraph Routes {
hub -> a
hub -> b;
hub;
}- One root document:
graphordigraph, optionallystrict, optionally named (the source keywordgraphmaps to the library kindundigraph; in this librarygraphalways means "either kind"). - Bare identifiers (ASCII and raw non-ASCII bytes), numeral, and quoted
identifiers (including quoted
+concatenation), node statements, single-edge statements and edge chains; semicolons are optional, as in Graphviz. - Basic attributes: standalone assignments, graph/node/edge attribute statements, and node/edge lists. Duplicate keys and written order are preserved.
- Named, anonymous and nested subgraphs, including edge endpoints, with allocation-free scope views.
- Borrowed source spans, explicit caller memory, fixed-buffer operation.
- Fixed-storage bounded sessions, with optional cooperative cancellation.
- Typed compile-time policies and opt-in runtime overrides, including named
standardandlenientpresets. - Comments (
//,/* ... */, and#line comments), skipped without retention. - Port suffixes (
a:out,a:n,a:out:e) on node statements and node endpoints.
Bare identifiers such as café and 東京 preserve their bytes exactly;
parsing does not validate UTF-8 or normalize Unicode.
HTML-like identifiers and the planned standalone markup subsystem are not part of 0.3.0; implementation follows this release. Other features, including semantic edge-product expansion, remain deferred. The authoritative construct-by-construct table is docs/SUPPORTED_SYNTAX.md.
See the attribute example for fixed-storage parsing and ordered attribute traversal. Parsing does not apply defaults or resolve values. See the port example for compact node references and raw suffix traversal. Parsing does not resolve named ports or compass attachments. See subgraph endpoints for a uniform node/scope endpoint switch; parsing never eagerly expands node-to-node edge products. See subgraph traversal and the example for direct/recursive scope views and explicit nesting scratch.
const std = @import("std");
const dot = @import("dot_parser");
var bag: dot.FixedDiagnosticBag(16) = .{};
var checked = dot.parseAndValidate(allocator, source, bag.sink(), .{});
defer checked.deinit(allocator);
if (checked.documentValid()) {
const document = checked.document.?;
var statements = document.statements();
while (statements.next()) |statement| {
switch (statement) {
.subgraph => |id| std.log.info("subgraph scope {d}", .{@intFromEnum(id)}),
.edge_chain => |chain| std.log.info("chain {s}: {d} edges", .{
document.text(document.nodeReference(chain.first.left.node).?.identifier), document.edgeLinkCount(chain) + 1,
}),
.node => |node| std.log.info("node {s}", .{document.text(document.nodeReference(node.reference).?.identifier)}),
.edge => |edge| std.log.info("edge {s} {s} {s}", .{
document.text(document.nodeReference(edge.left.node).?.identifier),
edge.operator.lexeme(),
document.text(document.nodeReference(edge.right.node).?.identifier),
}),
.assignment => |assignment| std.log.info("assignment {s} = {s}", .{
document.text(assignment.key), document.text(assignment.value),
}),
.attribute_statement => |attributes| std.log.info("{s} attributes: {d}", .{
@tagName(attributes.target), attributes.attributes.len,
}),
}
}
}The quick-start switch uses .node endpoints because its input is node-only.
For arbitrary DOT, handle both Endpoint variants as shown in the
subgraph endpoint example.
Parsing is fail-fast by default: one syntax error, then the outcome. Ask for
statement-level recovery and one run reports them all, resynchronizing at the
next ; or }:
const Parser = dot.Profile(.{ .policy = .{ .recovery = .statements } });
var checked = Parser.parseAndValidate(allocator, source, bag.sink(), .{});
// checked.outcome == .invalid_syntax; bag holds every syntax error, in order.
try dot.console.renderBoxedList(bag.items(), bag.omitted, .{ .source = source }, stdout);Every diagnostic is a typed value — a WDP code such as E.Syntax.Keyword.003,
a span, a payload naming what was found and where in the grammar, and, when
one edit is known to repair it, a typed fix a linter can apply (with an
applicability flag saying whether it may do so unattended) — so a custom
renderer or an auto-fixer can do as much as the console renderer. The
registry and the fix table are in OUTCOMES.md;
examples/check_file.zig is a ready-made command-line checker.
For a pairwise engine-adapter view, use document.edgeIterator(). It visits
single edges and chain links as EdgeView values in operator source order without
allocating; chain attributes are shared. See the chain example for fixed-pool sizing
and ownership for the retained layout.
parseBorrowed and validate are separate stages, and the document is a
plain source-ordered view — ranges slice your buffer, and full positions
are derived only when you ask:
// A tiny lint: flag node names longer than 8 bytes.
var statements = document.statements();
while (statements.next()) |statement| switch (statement) {
.node => |node| {
const name = document.nodeReference(node.reference).?.identifier;
if (name.len > 8) {
const where = name.locate(document.source);
std.log.warn("{d}:{d}: long node name '{s}'", .{
where.line, where.byte_column, document.text(name),
});
}
},
.subgraph, .edge, .edge_chain, .assignment, .attribute_statement => {}, // This lint only checks nodes.
};For fixed-memory operation, hand parseBorrowedIn your own pools — no
allocator, nothing grows, and capacity is visible in the declarations:
var storage: dot.FixedDocumentStorage(.{
.statements = 32,
.nodes = 32,
.edges = 16,
}) = .{};
var bag: dot.FixedDiagnosticBag(8) = .{};
const parsed = dot.parseBorrowedIn(source, .{ .document = storage.storage() }, bag.sink(), .{});
if (parsed.outcome == .success) {
const validation = dot.validate(&parsed.document.?, bag.sink(), .{});
_ = validation;
}
// release by reusing or discarding the storage — there is nothing to freeThe example above reserves only flat syntax. For subgraphs, also reserve
.subgraphs in the document pools and pass .scratch = scratch.storage() from
FixedParseScratch(.{ .nesting = max_active_depth }) in the memory bundle.
The root has depth zero; siblings reuse frames.
(The allocator-based calls also accept arenas and
std.heap.FixedBufferAllocator with document_capacities hints, if an
allocator fits your architecture better.)
measure is a count-only dry run: the same grammar and limits, nothing
retained, and the exact DocumentCapacities a retained parse of that source
needs. Use it to size fixed pools for an input you do not know in advance,
or as the hint that keeps an arena parse allocation-exact:
const measured = dot.measure(allocator, source, bag.sink(), .{});
if (measured.capacities) |capacities| {
var checked = dot.parseAndValidate(arena.allocator(), source, bag.sink(), .{
.parse = .{ .document_capacities = capacities },
});
// ...
}This matters for arenas: growing pools leave every outgrown copy behind, so
an unhinted parse into an arena backs a document with four to seven times its
retained size. Hinted or measured parses reserve once. Fixed pools are a
memory and determinism feature rather than a speed one — the parse performs
at most a few dozen allocations either way. measureIn is the allocator-free
twin, taking the same nesting scratch as parseBorrowedIn.
Coming in a later slice: a consumer-neutral DotIR plus adapter contracts,
so engines consume normalized semantics rather than surface syntax.
document.text(range) always returns the exact source spelling, including
quotes and concatenation. Decode explicitly when you need the logical value:
var value_buffer: [128]u8 = undefined;
const reference = document.nodeReference(node.reference).?;
const value = try document.decodeIdentifier(reference.identifier, &value_buffer);
// Or stream without a decoded-value buffer:
try document.writeIdentifier(reference.identifier, writer);Decoding performs no allocation or numeric conversion. See ownership and decoding and the runnable identifier example.
The source bytes are borrowed: keep them alive and unchanged for as long as the returned document is used. See examples/ for runnable versions of these paths and docs/BASELINES.md for measured performance.
Tested with Zig 0.16.0, also the declared minimum toolchain version. Newer Zig versions are not yet verified.
zig build test # unit + public integration tests
zig build examples # build and run the examples
zig build check-freestanding # consumed session profiles for RISC-V32/Wasm32
zig build bench -Doptimize=ReleaseFast -Dlexer=block # every bench takes scalar|blockThe library target has no OS, network, or filesystem dependency: it parses caller-supplied bytes, so input can come from a file, a pipe, a socket, or generated in memory — reading it is the application's job.
Two scanner backends share one interface and produce identical results: the
byte-at-a-time scalar scanner (the default) and a 64-byte block scanner that
classifies input with vector compares, which wins when sessions run on very
small work budgets or the input is dominated by long identifiers, strings or
comments in the recorded benchmarks. Select one for parsing with
dot.Profile(.{ .policy = .{ .scanner = .block } }).
Bounded execution has the trade-off.
- Checking whether your DOT files will parse? → Supported DOT syntax
- Deciding who owns what, or working without an allocator? → Ownership and memory
- Yielding during parsing or supporting cancellation? → Bounded execution · runnable example
- Handling results, or telling malformed apart from not-yet-supported? → Outcomes and diagnostics
- Configuring limits, recovery, execution, graph kinds or runtime overrides? → Policies · runnable example
- Learning by running code? → examples/
- Performance numbers → Baselines · Architecture → Project structure · Release history → Changelog
Licensed under either of
- MIT license (LICENSE-MIT)
- Apache License, Version 2.0 (LICENSE-APACHE)
at your option (MIT OR Apache-2.0).
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work by you shall be dual licensed as above, without any additional terms or conditions.