GeoJSON (RFC 7946) for Zig, read and written with std.json, built on zig-geo.
  • Zig 90.1%
  • Nix 9.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 02c9ae8aa8
All checks were successful
test / docs (push) Successful in 6m39s
test / test (push) Successful in 7m36s
Rename the package to geojson, release 0.3.0
The package was named zig_geojson, so a dependent wrote
b.dependency("zig_geojson", ...) to reach a module called geojson. It is
now geojson throughout. The fingerprint's checksum half follows the new
name; its id half is unchanged, so this is the same package and not a fork.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JPx2vKtR1Mc1Upt4s25ZtS
2026-10-07 00:33:00 -05:00
.forgejo/workflows GeoJSON, read and written with std.json 2026-09-24 00:17:01 -05:00
LICENSES GeoJSON, read and written with std.json 2026-09-24 00:17:01 -05:00
src Port to Zig 0.17 2026-10-06 18:53:44 -05:00
tests Port to Zig 0.17 2026-10-06 18:53:44 -05:00
tools Port to Zig 0.17 2026-10-06 18:53:44 -05:00
.gitignore GeoJSON, read and written with std.json 2026-09-24 00:17:01 -05:00
build.zig Port to Zig 0.17 2026-10-06 18:53:44 -05:00
build.zig.zon Rename the package to geojson, release 0.3.0 2026-10-07 00:33:00 -05:00
build.zig.zon.nix Port to Zig 0.17 2026-10-06 18:53:44 -05:00
flake.lock Port to Zig 0.17 2026-10-06 18:53:44 -05:00
flake.nix Port to Zig 0.17 2026-10-06 18:53:44 -05:00
package.nix Rename the package to geojson, release 0.3.0 2026-10-07 00:33:00 -05:00
README.md Rename the package to geojson, release 0.3.0 2026-10-07 00:33:00 -05:00
REUSE.toml GeoJSON, read and written with std.json 2026-09-24 00:17:01 -05:00

zig-geojson

GeoJSON (RFC 7946) for Zig 0.17, read and written with std.json.

Every type here is an ordinary std.json type with the hooks that make it parse and stringify as GeoJSON. So a Geometry or a Feature can be a field of anything else std.json reads, such as an API response that embeds GeoJSON in a document of its own. Positions are zig-geo's Position, so a geometry read from a document can be asked where it is, what box it fits in and what it contains, with no conversion.

The API documentation is generated from the doc comments, which carry most of the explanation. The workflow publishes it from main to https://jeff.jcollie.page/zig-geojson/.

Where this lives

The repository is in three places, all with the same history. The Forgejo instance at https://git.jcollie.dev/jeff/zig-geojson is the one with a web view:

$ git clone https://git.jcollie.dev/jeff/zig-geojson.git

It is mirrored on Tangled at https://tangled.org/jcollie.dev/zig-geojson. It is also on the Radicle network, with the repository identifier

rad:z2x9jTH1mKqtKcGYsWyePiHXJWCkZ

rad clone rad:z2x9jTH1mKqtKcGYsWyePiHXJWCkZ fetches it from any node that seeds it. Any of the three is the whole project.

Adding it to a project

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-geojson
const geojson = b.dependency("geojson", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("geojson", geojson.module("geojson"));

It depends on zig-geo, and re-exports it as geojson.geo, so a program has zig-geo's functions without a dependency of its own.

For Zig 0.16.0, use the zig-0.16 branch, which holds the last of zig-geojson to build with it. The v0.1.0 tag is the same commit:

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-geojson#zig-0.16

The package is named zig_geojson there, so that is the name to give b.dependency.

Using it

const geojson = @import("geojson");

const Properties = struct { name: []const u8 };
const parsed = try std.json.parseFromSlice(geojson.FeatureCollection(Properties), gpa, text, .{});
defer parsed.deinit();

const here: geojson.Position = .{ .longitude = -93.625, .latitude = 41.5868 };
for (parsed.value.features) |feature| {
    const area = feature.geometry orelse continue;
    if (area.contains(here)) std.debug.print("{s}\n", .{feature.properties.name});
}

try std.json.Stringify.value(parsed.value, .{}, writer);
Type What it is
Geometry one of RFC 7946's seven kinds, from point to geometry_collection
Feature(P) a geometry (or none), an id, a bbox, and properties of type P
FeatureCollection(P) features, and a bbox
Object(P) any GeoJSON text, told apart by its type

The properties' type is the caller's choice:

  • a struct, for documents whose shape is known;
  • std.json.Value, for documents whose shape isn't;
  • ?T, when features may have no properties at all.

A Geometry can say whether it contains a position (the areal kinds only) and what its boundingBox is. clone gives it an allocator of its own, which is how a geometry is kept after the parse's arena is freed.

What is read, and what is refused

RFC 7946 asks readers to be lenient in some places and not others, and this reader follows it:

  • Foreign members (§6.1) on a Feature or a collection are passed over, whatever ignore_unknown_fields says. The National Weather Service, for one, puts @context on everything. A feature's properties are parsed with the caller's options, because their shape is the caller's business.
  • Positions: a position may carry more than three numbers. The third is the altitude and the rest are dropped (§3.1.1).
  • Rings are read whether or not they close. RFC 7946's MUSTs about shape (lines at least two positions, rings at least four and closed, positions in range) are checked separately by Geometry.validate. Its SHOULD about ring orientation is Geometry.followsRightHandRule.
  • type is required to tell a geometry's kind or an Object apart. On a Feature or a FeatureCollection it is checked if present and not required.
  • Refused: an unknown geometry type, coordinates nested to the wrong depth, a number too large to be finite (1e999), and GeometryCollections nested more than 16 deep. RFC 7946 §3.1.8 says they shouldn't nest at all, and the limit keeps a hostile document from exhausting the stack.
  • Numeric ids: a whole-number id is read as an integer however it is spelled, so -1e2 and -100 are the same id. The fuzzer found that without this an id didn't read back the same after being written.

A geometry's own bbox isn't kept. It says nothing its coordinates don't, and boundingBox computes it. A Feature's or a collection's bbox is kept.

Building and testing

Everything runs inside the flake's devshell:

$ nix develop
$ zig build test --summary all        # unit tests, the RFC's examples, and the fuzz seeds
$ zig build check                     # also compiles the fuzz driver and the docs server
$ zig fmt --check --exclude zig-pkg .
$ reuse lint
$ nix build                           # the package, built and tested offline

tests/rfc7946/ holds the examples from RFC 7946 §1.5 and Appendix A, extracted from the RFC as they stand. Every one is read, validated, written and read back. Writing it a second time must produce the same bytes.

Fuzzing

The fuzz target reads arbitrary bytes as GeoJSON. Whatever it reads must write back out and read back as the same thing, and validating it, measuring it and testing it for containment must neither panic nor leak. Zig's own fuzzer runs it with coverage feedback:

$ zig build fuzz --fuzz                                # until interrupted, with a web interface
$ zig build fuzz --fuzz=1M                             # a bounded run, then a report

The test binaries are compiled with LLVM (use_llvm in build.zig), since the self-hosted backend Debug otherwise uses emits no coverage instrumentation. tools/fuzz.zig drives the same targets with a mutation loop of its own, bounded by a count so that CI runs the same inputs every time:

$ zig build fuzz-run -- --iterations 60000             # what CI runs
$ zig build fuzz-run -- --seconds 600
$ zig build fuzz-run -- --input fuzz-findings/x.bin --target objects

API documentation

$ zig build docs         # into zig-out/docs
$ zig build docs-serve   # and read them at http://127.0.0.1:8000/

The docs have to be served rather than opened as files. The viewer is a WebAssembly program that fetches its sources at runtime, and a browser refuses that from a file:// page.

References cited

Butler, H., M. Daly, A. Doyle, S. Gillies, S. Hagen, and T. Schaub. 2016. The GeoJSON Format. RFC 7946. RFC Editor. https://www.rfc-editor.org/info/rfc7946. The format, and the source of the examples in tests/rfc7946/.

IETF Trust. 2015. "Legal Provisions Relating to IETF Documents." Version 5.0, corrected 2021. https://trustee.ietf.org/documents/trust-legal-provisions/. §4.c licenses the Code Components of an IETF document, including the examples above, under the Revised BSD licence. RFCs of RFC 7946's era call it the "Simplified BSD License"; the Trust has since corrected that name.

Licence

MIT, and REUSE compliant: every file says what it is licensed under, and reuse lint checks that. The examples in tests/rfc7946/ are the IETF's, under BSD-3-Clause, as REUSE.toml records.