- Zig 93.9%
- Nix 6.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The loop is single-threaded and the machine is not. What is left to find turns up at about one input in thirty million, so six campaigns with different seeds are six times the chance of meeting one in the same wall-clock -- which is how the last four findings were found, after a single campaign of fifty million had come back clean. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
zig-gpx
A sans-IO reader and writer for GPX, the GPS Exchange Format, in Zig 0.16.
Sans-IO means what it says: nothing here opens a file, connects to anything, or
blocks. The reader is handed bytes and hands back events; the writer is handed
a std.Io.Writer and writes to it. Where the bytes come from and where they go
is the caller's business, which is what lets the same code read a track out of
an HTTP response, a memory-mapped file, a @embedFile, or a decompressor's
output buffer.
The API documentation is generated from the doc comments, which is where most of the explanation lives.
Where this lives
The repository's home is my Forgejo instance, which is where the issues, the continuous integration and the published documentation are:
$ git clone https://git.jcollie.dev/jeff/zig-gpx.git
It is mirrored on Tangled at https://tangled.org/jcollie.dev/zig-gpx, a forge built on the AT Protocol, where a repository is addressed by its owner's identity rather than by a server name:
$ git clone https://tangled.org/jcollie.dev/zig-gpx
It is also published on Radicle, a peer-to-peer forge that needs no account on anything. A Radicle repository is findable only by its repository ID, so this is that ID:
$ rad clone rad:z3xmKn8khWvxqvE35GxBVKWvaKWCF
rad clone finds seeds through your local node's routing table rather than
through a known host, so the node has to be running before it can find
anything:
$ rad node start
If you already have the repository and only want to help host it, seeding it tells your node to carry a copy for others:
$ rad seed rad:z3xmKn8khWvxqvE35GxBVKWvaKWCF
Any of the three is the whole project, on the main branch, with the same
history.
Installation
$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-gpx.git
const gpx = b.dependency("gpx", .{
.target = target,
.optimize = optimize,
});
your_module.addImport("gpx", gpx.module("gpx"));
Usage
There are two ways in, and which one to use is decided by the question being asked.
Reader pulls one point at a time and builds nothing. A day of hiking
logged once a second is ninety thousand track points and six megabytes, and the
questions usually asked of one — how far, how long, how much climb — need the
points one at a time.
const gpx = @import("gpx");
var reader: gpx.Reader = .init(gpa, source, .{});
defer reader.deinit();
var total: f64 = 0;
var previous: ?gpx.Point = null;
while (true) switch (try reader.next()) {
.track_point => |p| {
if (previous) |q| total += q.distanceTo(p);
previous = p;
},
// The pen lifts between segments: the gap is where the recording
// stopped, not distance that was travelled.
.segment_end => previous = null,
.end => break,
else => {},
};
Document reads the whole file into one arena, which is what the other
questions need — a bounding box, the file written back out with something
changed, anything that has to look at two points that are not adjacent.
var doc = try gpx.Document.parse(gpa, source, .{});
defer doc.deinit();
for (doc.tracks) |track| {
std.debug.print("{s}: {d:.2} km\n", .{
track.name orelse "(unnamed)",
track.length() / 1000,
});
}
const out = try doc.toOwnedSlice(gpa, .{ .indent = "\t" });
defer gpa.free(out);
Writing without a document in hand is a third way, and the one a converter
wants: Writer takes a std.Io.Writer and the elements in the order the
format puts them.
var writer: gpx.Writer = .init(out, .{ .creator = "my program" });
try writer.begin(.{ .version = .@"1.1" });
try writer.beginTrack(.{ .name = "morning" });
try writer.beginSegment();
try writer.trackPoint(.{ .lat = 41.7396496, .lon = -93.6313859, .elevation = 293.3 });
try writer.endSegment(null);
try writer.endTrack();
try writer.end();
What it does
- Reads both versions of the format. GPX 1.1 is what everything writes
today; GPX 1.0 is what a decade of devices and a great many archives hold,
and it reads into the same types — its metadata is loose in the root element
rather than in a
<metadata>, and its<url>and<urlname>pair is aLink. Which version a document is written as is one field of the writer's options, so converting between them costs nothing. - Keeps
<extensions>verbatim. What is inside one belongs to whoever put it there — Garmin's heart rate and cadence, OsmAnd's speed, GDAL's projection — so the whole element is kept as a slice of the document, start tag and all, and written back out untouched. Nothing here has to know any of them, and none of them are lost. The start tag is included rather than only what is between the tags because a namespace may be declared on the element itself —<extensions xmlns:q="urn:y">scopesqto exactly the subtree that uses it — and a copy without that declaration is a subtree no namespace-aware reader accepts. The declarations on the root element are kept for the same reason, which is what makes a Garmin file'sns3:prefix still mean something after a round trip. - Allocates almost nothing while streaming. A string is a slice of the
document unless it had an entity reference to resolve, and the
<extensions>never copy at all, so reading ninety thousand points touches the allocator a handful of times rather than a hundred thousand. - Writes in the order the schema asks for. GPX is a sequence and not a bag:
<ele>before<time>, every<wpt>before the first<rte>,<extensions>last. Calling the writer's methods out of that order is a programming error and trips an assertion rather than producing a file that a validating reader rejects and a careless one misreads. - Does not borrow the source. Every string in a
Documentis copied into its arena, so the bytes it was parsed from may be freed the momentparsereturns.
The gpx tool
A library nobody can point at a file is hard to believe in, and this is what the corpus of real tracks gets run through.
$ gpx info 2026-06-21_09-13_Sun.gpx
2026-06-21_09-13_Sun.gpx
GPX 1.1 by OsmAnd+ 5.3.10
name 2026-06-21_09-13_Sun
waypoints 0
routes 0
tracks 1
[0] (unnamed): 2 segments, 469 points, 22.32 km
2026-06-21T14:13:00Z to 2026-06-21T14:58:05Z (0h45m)
bounds 44.067280,-93.229499 to 44.099923,-93.051571
points 469
gpx points writes every point as a line of latitude, longitude, elevation and
time, which is what a spreadsheet or an awk one-liner wants. gpx fmt reads a
file and writes it back out, optionally as the other version of the format
(--gpx-1.0, --gpx-1.1) or with no whitespace at all (--compact).
--stream makes info and points use the streaming reader rather than
building a document, which is what a file of a few hundred megabytes wants.
Dates and times
A <time> is an instant, and an instant here is zig-datetime's Instant —
nanoseconds since the Unix epoch — rather than a type of this library's own, so
everything that library can do with one is available without converting
anything. gpx.datetime is that library re-exported, so depending on this one
is enough.
What this library decides is only which of ISO 8601's many shapes count as a
GPX timestamp and which one to write back. It reads more than xsd:dateTime
asks for — the basic form, an ordinal or week date, a comma for the decimal
point, 24:00 as the end of its day, and a timestamp with no zone at all,
which the schema does not allow and which every GPX file that writes one means
as UTC. It reads less in one place: a date alone is not an instant, so anything
short of whole seconds is refused rather than silently completed with zeros.
Everything is written back as UTC with a Z, because that is the one form
every reader agrees on.
What is not here
- No validation against the schema. Reading is lenient by design: an
element GPX does not define is skipped, because GPX is extended constantly
and often carelessly — by tools that put their own elements straight into a
<trk>rather than inside its<extensions>— and a reader that refused those would refuse a great many real files.Options.unknown_elementsturns that into an error for a caller that wants one. - No geodesy beyond a distance.
Point.distanceTois the haversine formula on a sphere of the earth's mean radius, which is good to about 0.3% against the WGS 84 ellipsoid and far better than the points themselves. Anything needing more than that wants a library about ellipsoids, not one about XML. - No reprojection, no smoothing, no simplification. Those are things to do with a track, and this is the part that gets the track in and out.
- Nothing fetched, ever. The XML underneath this is zxml, which records
a
SYSTEMorPUBLICidentifier and never dereferences one, so a document cannot make this open a file or reach the network.
Testing
$ zig build test
That runs the unit tests and replays the fuzz corpus. The property worth knowing about is the strong one:
$ zig build fuzz-run -- --seconds 600
$ zig build fuzz-run -- --iterations 500000 --target convert
Writing what was read, and reading that, must produce the same bytes a third time. A reader and a writer drift apart easily, because nothing else compares them: a field read and never written, or written in a place the reader does not look for it, is invisible until somebody's waypoints come back without their symbols. Comparing the fixed point rather than the first pass is what lets a deliberate normalization — a time rewritten as UTC, a number rewritten shortest, a 1.1 document written as 1.0 — pass while a loss does not.
A second property rides along: the source is freed before the document is
written. Every string in a Document is copied into its arena precisely so
that it outlives the bytes it came from, and the <extensions> are where that
is easiest to get wrong, since the streaming reader hands those out as slices
of the file it is reading.
The loop is single-threaded and the machine is not, so several campaigns with different seeds find more than one long campaign does: the bugs that are left turn up at around one input in thirty million, and six at once is six times the chance of meeting one.
$ for i in 1 2 3 4 5 6; do
zig build fuzz-run -- --seconds 1200 --seed $i > run$i.log 2>&1 &
done
The loop is this project's own rather than Zig's, because Zig 0.16.0's fuzzer cannot be used — a test executable will not build in fuzz mode without the one-line standard library patch the devshell applies, and even with it the table of program counters comes back empty, so there is no coverage feedback to have. What the loop has instead is a corpus of inputs that already parse, which for a parser is most of the way there.
A formatting check skips zig-pkg, which is where the package manager puts
the dependencies: somebody else's source, which this project does not get to
reformat.
$ zig fmt --check --exclude zig-pkg .
Documentation
$ zig build docs # into zig-out/docs
$ zig build docs-serve # and read it at http://127.0.0.1:8000
It has to be served rather than opened: the viewer fetches its sources and its
WebAssembly at runtime, which a browser refuses to do from a file:// page.
References cited
- [GPX 1.1] TopoGrafix, "GPX 1.1: the GPS Exchange Format", 9 August 2004,
https://www.topografix.com/GPX/1/1/. The format this implements. The
schema at https://www.topografix.com/GPX/1/1/gpx.xsd is the normative part
and is what the writer's element order is taken from:
gpxType,metadataType,wptType,rteType,trkTypeandtrksegTypeare each a sequence, so the order the writer emits in is theirs rather than a choice. - [GPX 1.0] TopoGrafix, "GPX 1.0: the GPS Exchange Format", 2002,
https://www.topografix.com/gpx_manual.asp. The older version, read but
written only when asked for. Its schema is where
<speed>and<course>live — on a<trkpt>alone, after<time>and before<magvar>, which is not where a reader would guess — and where the<url>/<urlname>pair that became<link>is defined. - [XML] Bray, T., Paoli, J., Sperberg-McQueen, C. M., Maler, E., and
F. Yergeau, "Extensible Markup Language (XML) 1.0 (Fifth Edition)", W3C
Recommendation REC-xml-20081126, 26 November 2008,
https://www.w3.org/TR/2008/REC-xml-20081126/. §2.11 for the line-ending
normalization and §3.3.3 for the attribute-value normalization that decides
what the writer has to escape: a literal tab in an attribute comes back as a
space and a
	comes back as a tab, which is the difference a round trip notices. - [XML Names] Bray, T., Hollander, D., Layman, A., Tobin, R., and
H. S. Thompson, "Namespaces in XML 1.0 (Third Edition)", W3C Recommendation
REC-xml-names-20091208, 8 December 2009,
https://www.w3.org/TR/2009/REC-xml-names-20091208/. Why an unprefixed
attribute is in no namespace whatever the default is bound to, which is what
decides the prefix an
xsi:schemaLocationis written with. - [XSD 2] Biron, P. V. and A. Malhotra, "XML Schema Part 2: Datatypes
Second Edition", W3C Recommendation REC-xmlschema-2-20041028, 28 October
2004, https://www.w3.org/TR/2004/REC-xmlschema-2-20041028/. The lexical
spaces GPX's values are drawn from: §3.2.7
dateTime, §3.2.3decimal— which has no exponent and noINForNaN, and is why the numbers are checked before they are parsed — and §3.2.8gYear.