- Zig 97.6%
- JavaScript 1.1%
- C++ 0.3%
- Nix 0.3%
- C 0.3%
- Other 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The chained leap.is line measured the previous @mod(year, 100) form faster, 2.8 ns to 3.4, which went unexplained when the @rem(year, 25) form was adopted. Inlined into that loop, LLVM compiles the old form's choice of mask to a branch and the new one's to a conditional move. The benchmark's years are uniform over Year, so the branch is almost always predicted "not a century" and the division leaves the critical path; the conditional move has to wait for it. With a fresh random year every call and half of them centuries, the old form took 7.2 ns and the new 5.3. leap.is's doc comment now says so, and the benchmark's comment says what to make of the two lines disagreeing. No code changes. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01LhqkVWKsh2rjnPEJ64ZUke |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tools | ||
| upstream | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| GLOSSARY.md | ||
| README.md | ||
| REUSE.toml | ||
zig-datetime
Dates, times, and timezones for Zig 0.17.
API documentation: https://jeff.jcollie.page/zig-datetime/, published from
main by .forgejo/workflows/test.yaml.
Where this lives
The repository is hosted on Forgejo, which is where the issues, the continuous integration and the published documentation are:
git clone https://git.jcollie.dev/jeff/zig-datetime.git
It is mirrored to GitHub, and that is the copy Zig fetches from, since a
zig fetch URL is read by whoever depends on this and GitHub is the more
reachable of the two:
git clone https://github.com/jcollie/zig-datetime.git
It is mirrored on Tangled at https://tangled.org/jcollie.dev/zig-datetime, a forge built on the AT Protocol, where the repository is addressed by its owner's identity rather than by a server name.
It is also published on Radicle, a peer-to-peer code 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:zrcUiTVhYvJSzwRyCBNmfM9oHkSo
rad clone finds seeds through your local node's routing table, so the node
has to be running first, and cloning seeds the repository in turn, which helps
keep it available:
rad node start
If you already have the repository and only want to help host it:
rad seed rad:zrcUiTVhYvJSzwRyCBNmfM9oHkSo
The Radicle copy is public, named zig-datetime, and its default branch is
main — the same name and branch as the others, so any of the four gives the
same history.
Adding it to a project
zig fetch --save git+https://github.com/jcollie/zig-datetime
const datetime = b.dependency("datetime", .{
.target = target,
.optimize = optimize,
});
your_module.addImport("datetime", datetime.module("datetime"));
Timezones
A TimeZone says what a clock in some place reads at a given instant. The
rules come from the IANA timezone database, which this library reads in its
compiled form, TZif (RFC 8536). There are two places to get that data from:
the copy the operating system already has, or a copy compiled into your
binary.
Using the system's copy
This is the usual choice on Linux, macOS, and the BSDs. It needs no build options, stays current when the system updates its data, and adds nothing to your binary.
Two environment variables decide where the data is and which zone the user
wants: TZDIR names the directory holding the TZif tree, and TZ
names the zone.
This library does not read either of them for you. Since Zig 0.16 a
program receives its environment through the std.process.Init passed to
main, so a library that went looking for it would have to reach for
globals behind your back. Instead you read the environment and pass the
answer in, which also means tests and sandboxes can point the library
somewhere else without touching the real one.
The short version, when you know which zone you want:
const std = @import("std");
const datetime = @import("datetime");
pub fn main(init: std.process.Init) !void {
// TZDIR names the directory; fall back to the usual location.
const directory = init.environ_map.get("TZDIR") orelse
datetime.tzdb.system.default_directory;
var zone = try datetime.tzdb.system.load(
init.io,
init.gpa,
directory,
"America/Chicago",
);
defer zone.deinit(init.gpa);
// 2024-07-15 12:00 UTC, in Chicago.
const local = zone.atTimestamp(1721044800);
std.debug.print("{d}:{d:0>2} {s}\n", .{
local.hour,
local.minute,
zone.typeAt(1721044800).designation,
}); // 7:00 CDT
}
default_directory is /usr/share/zoneinfo, which is right on most
systems but not all: NixOS uses /etc/zoneinfo, and some older Unixes use
/usr/lib/zoneinfo or /usr/share/lib/zoneinfo. If you want to work
everywhere without requiring TZDIR to be set, try each in turn —
tzdb.system.search_directories lists them in a sensible order:
fn openZone(io: std.Io, gpa: std.mem.Allocator, environ: *std.process.Environ.Map, name: []const u8) !datetime.TimeZone {
if (environ.get("TZDIR")) |dir| {
return datetime.tzdb.system.load(io, gpa, dir, name);
}
for (datetime.tzdb.system.search_directories) |dir| {
return datetime.tzdb.system.load(io, gpa, dir, name) catch continue;
}
return error.NoTimezoneDatabase;
}
Honoring the user's TZ
TZ is not simply a zone name. It may be an absolute path to a TZif file,
or a complete POSIX rule that names no file at all, and by long convention
a leading colon introduces a name or a path.
tzdb.system.resolveTz sorts those cases apart without touching the
filesystem:
const zone = switch (datetime.tzdb.system.resolveTz(init.environ_map.get("TZ"))) {
// TZ unset: the machine's own zone, which /etc/localtime is a copy of.
.local => try datetime.tzdb.system.loadLocal(init.io, init.gpa, null),
// TZ=/etc/localtime, or TZ=:/usr/share/zoneinfo/America/Chicago
.path => |path| try datetime.tzdb.system.loadLocal(init.io, init.gpa, path),
// TZ=America/Chicago, TZ=:America/Chicago, TZ=Etc/GMT+5
.name => |name| datetime.tzdb.system.load(init.io, init.gpa, directory, name) catch |err| switch (err) {
// Not a zone after all, so it must have been a rule. See below.
error.FileNotFound => return parseAsRule(name),
else => return err,
},
// TZ=CST6CDT,M3.2.0,M11.1.0 — a rule, with no file behind it.
.rule => |rule| return parseAsRule(rule),
// TZ="" is UTC.
.utc => ...,
};
That .name case really does need the fallback, because a rule and a zone
name cannot always be told apart by looking at them. EST5EDT, MST7MDT,
GMT0 and every Etc/GMT+5 are real zone names and well-formed POSIX
rules meaning roughly the same thing. Guessing from the shape of the text
gets all forty of them wrong, so resolveTz assumes a name and lets the
filesystem settle it — the same thing glibc and musl do. Only a value
containing a comma, or starting with <, is reported as a definite
.rule, since no zone name contains either.
Zone names taken from TZ are attacker-controlled in some deployments, so
load validates them before joining them onto the directory. A name like
../../etc/passwd is refused rather than opened.
Embedding the database instead
If you would rather not depend on the host having timezone data — a
scratch container, a cross-compiled binary — or if you are targeting
Windows, which has none of the kind this reads, build with
-Dembed-tzdata and the database goes into the binary:
zig build -Dembed-tzdata
// No allocator and no filesystem: the data is already in the binary and
// the zone borrows it.
var zone = (try datetime.tzdb.embedded.load("America/Chicago")).?;
This fetches the IANA sources and builds zic, the reference compiler,
from the C sources IANA publishes alongside the data, using the C compiler
Zig already ships. Nothing needs to exist on the host: no zic, no
timezone data, no C toolchain. The output is byte-for-byte what the
reference implementation produces.
| Option | Default | Effect |
|---|---|---|
-Dembed-tzdata |
off | Compile the database into the binary |
-Dtzdata-packing=slim|fat |
slim |
slim leans on each zone's POSIX rule for repeating years (~347 KB); fat writes every transition out (~702 KB) |
-Dtzdata-from=@0 |
keep all | Drop transitions before this point; @0 keeps 1970 onwards (~255 KB) |
-Dembed-locales |
off | Compile moment.js's 137 locales into the library; see Locales |
Without -Dembed-tzdata nothing is fetched and no C is compiled, so an
ordinary build stays fast and offline. tzdb.embedded.available tells you
at comptime whether a build has data.
Windows
Windows has no zoneinfo tree. It keeps its own zone data in the registry,
under its own names — Central Standard Time rather than
America/Chicago — in its own format, with none of the history IANA
carries. So tzdb.system cannot work there at all, and says so:
tzdb.system.available is false, search_directories is empty, and
load and loadLocal fail with error.NoSystemDatabase rather than
opening a Unix path that was never going to exist.
What does work is tzdb.windows, which asks Windows which zone the
machine is set to and translates the name:
// Needs -Dembed-tzdata: the name comes from Windows, the data does not.
var zone = try datetime.tzdb.windows.loadLocal();
That is three steps, and each is available on its own.
windows.localKeyName calls GetDynamicTimeZoneInformation and returns
its TimeZoneKeyName, which is the registry key the setting came from and
is the same string in every locale, unlike the display names beside it.
windows.ianaName looks that up in CLDR's table — the only published
correspondence between the two sets of names — and is a plain table lookup
that works on any target. tzdb.embedded.load then supplies the data,
which is why -Dembed-tzdata is not optional here.
A Windows zone usually covers several IANA ones: Central Standard Time
is Chicago in the United States, Winnipeg in Canada and Mexico City in
Mexico. The table takes CLDR's world-wide default, the answer that does
not need to know where the machine is, which is the same choice ICU makes
for a bare Windows name.
The registry is never read. Windows' own zone data would be a second binary format to parse, for less history than the copy already in the binary, and the two would disagree about the past.
Code written to work on both looks like this:
var zone = if (datetime.tzdb.system.available)
try datetime.tzdb.system.loadLocal(io, gpa, null)
else
try datetime.tzdb.windows.loadLocal();
The Win32 call comes from zigwin32, fetched lazily and only when the
target is Windows, so builds for anything else neither fetch nor compile
it. The name table is checked into the tree as src/windowszones.zig
rather than fetched, so the Windows path costs no network and no second
dependency; zig build windowszones -Dwindowszones-xml=… regenerates it
when CLDR publishes a new release.
The Windows path is not left to be right by inspection: CI cross-compiles
the test suite to Windows and runs it under Wine, which is where
localKeyName actually calls into Win32 and the name it returns is
actually looked up. To do the same locally:
nix develop .#windows -c zig build test \
-Dtarget=x86_64-windows -Dembed-tzdata -fwine
Local times that are ambiguous or do not exist
Converting an instant to a wall clock reading always works. Going the
other way does not: when clocks spring forward a range of readings never
happens, and when they fall back a range happens twice. resolve reports
which case you are in rather than quietly picking one:
// 02:30 on the day the clocks went forward — a reading that never happened.
switch (zone.resolve(.{ .year = 2024, .month = .Mar, .day = 10, .hour = 2, .minute = 30 })) {
.unique => |at| ...,
.gap => |gap| ..., // gap.before, gap.after, gap.at
.ambiguous => |both| ..., // both.earlier, both.later
}
resolved.earliest() and resolved.latest() pick a side when you do not
care which.
Formatting and parsing
There are six vocabularies a format string can be written in. The
moment.js sequences below are the general case; golayout, cldr,
strftime and dotnet are the same job said the way Go, the Unicode
Consortium, the C library and .NET say it, and powershell is the two
that PowerShell's Get-Date speaks. Each has a section of its own
further down.
Format strings are sequences of tags taken from moment.js, tokenized at compile time:
const text = try instant.asDateTime().formatAlloc(gpa, "YYYY-MM-DDTHH:mm:ssZ");
const parsed = try datetime.DateTime.parse("YYYY-MM-DD HH:mm:ss", "2024-07-15 07:00:00");
Anything that is not a sequence is copied through, so YYYY/MM/DD means
what it looks like. Square brackets make a literal of text that would
otherwise be read as sequences, and a backslash does the same for whatever
follows it: GGGG-[W]WW-E writes an ISO week date.
Parsing has moment's two modes. parse is the lenient one and parseWith
takes both the mode and the reference the unnamed fields come from:
// Lenient: a padded sequence takes one digit as well as two, a name
// sequence takes any of its lengths, and trailing text is left alone.
const loose = try datetime.DateTime.parse("MMM D YYYY", "March 5 2024 (approx)");
// Strict: exact widths, and the whole input has to be used.
const tight = try datetime.DateTime.parseStrict("MMM D YYYY", "Mar 05 2024");
const relative = try datetime.DateTime.parseWith("MMM D", "Mar 15", .{
.relative_to = base,
.mode = .strict,
});
ParseResult.str is the prefix that was consumed, so a caller can carry
on from value[str.len..]; skipped says how much inside it was stepped
over rather than read, which only lenient parsing does.
Compatibility with moment.js
The vocabulary is moment's, and that is checked rather than claimed.
zig build oracle runs moment itself over the same corpus and diffs every
answer: every sequence on its own, the shapes callers actually write,
escaping and its corners, and a day-by-day sweep from 2015 to 2032 read at
five offsets including a quarter-hour one.
796200 comparisons against moment 2.30.1, no divergence
zig build oracle-parse does the same for parsing, in both modes, holding
each to the matching mode of moment. moment is pinned in build.zig.zon
and fetched lazily, the way tzcode and tzdata are, because it is the
specification being tested against and a floating version would move the
target. Both run as part of zig build test.
Following moment means following it where it is surprising. Leniently a
sequence is searched for rather than required where it stands, so
2024/03/15 reads against YYYY-MM-DD and 14 against HH:mm is two
o'clock; w is the week starting Sunday that holds January 1st while W
is the ISO week, and each has its own week-numbering year in gg and
GG; YY of 70 is 1970 and of 68 is 2068; Hmm is one sequence and
not an hour beside a minute; and YYY is YY followed by Y.
Two places it does not follow moment, both deliberate:
-
Fractional seconds keep their precision. moment holds milliseconds and pads
SSSSonwards with zeros. This holds nanoseconds and prints them, soSSSSof.123456789is1234rather than1230. For any input moment can represent the two agree; beyond that the library is not going to lie about a value it has. -
zandzzare constant,UTCandCoordinated Universal Time, whatever the offset. That is moment's own behavior rather than a gap: it has no zone names at all, and real abbreviations come from moment-timezone, a separate package. The real one is on the value instead, put there by the zone that knows it:const local = zone.atTimestamp(1720000000); std.debug.print("{s}\n", .{local.designation.slice()}); // CDTIt is six bytes stored in the
DateTimerather than a slice into the zone, so the reading does not depend on the zone outliving it. Empty means not known, which is what a parsed date orInstant.asDateTimegives you: neither has a zone to ask.
Locales
The default locale is en, and every entry point that does not take one
uses it, so nothing changes for a caller that never asks. To ask, pass one:
var zone_locale = datetime.locale.byName(header) orelse .en;
try dt.formatWith("dddd D MMMM YYYY", zone_locale, writer); // mardi 5 mars 2024
const parsed = try datetime.DateTime.parseWith("dddd D MMMM YYYY", text, .{
.locale = zone_locale,
});
A locale is runtime data rather than a comptime parameter. The format string stays comptime, because it decides which code runs; the locale only decides which bytes come out, so one can come from a request header or a configuration file without a switch over everything you might have compiled in.
It carries more than names. Do writes 1er in French and 1. in
German; A is 午後 in Japanese; w counts from Sunday with January 1st
in week one for en and by the ISO rule for most of Europe, and e
numbers the weekday from whichever day the week starts on, where d is
always Sunday-based and E always ISO. And the L family really is
localized — the same sequence is a different date order:
try dt.formatWith("L", .en, writer); // 03/05/2024
try dt.formatWith("L", french, writer); // 05/03/2024
That is the one piece a locale changes at run time rather than renames: it stands for a whole format string, so the tokenizer runs over the locale's expansion. Everything else is the comptime-unrolled walk it always was.
-Dembed-locales compiles in moment's other hundred and thirty-six, and
locale.byName finds one by tag. Without it there is only en, and
nothing is fetched:
zig build -Dembed-locales
The data is moment's own, read out of its locale files by
upstream/src/gen_locales.js — a locale transcribed by hand would be a
divergence built in at the source. The table it writes,
src/locales/all.zig, is committed, so -Dembed-locales compiles a file
that is already here and fetches nothing; zig build gen-locales
regenerates it when moment makes a release. The two pieces moment holds as functions rather than data, the
meridiem and the ordinal, are enumerated rather than reimplemented: their
domains are finite, so what comes out is a table that answers the same
questions.
You can also write one by hand; Locale is a plain struct, and only the
names and the L strings have no default.
Compatibility, checked
zig build oracle-locale formats a corpus in every embedded locale and
diffs it against moment in the same locale:
97818 comparisons across the locales of moment 2.30.1
10067 known and documented
no divergence beyond those
The documented ones are three, and each is a limit rather than a bug:
-
Twenty-three locales rewrite the digits after formatting, and some the separators too — moment calls it
postformat, and Arabic, Hindi and Bengali among others use it. This library writes ASCII digits. The oracle proves the difference is confined to that by running moment's ownpostformatover this library's answer and requiring the result to match exactly, so every name, meridiem and ordinal still has to agree. -
moment writes
NaNfor the fortieth ordinal in Azerbaijani, Turkmen and Turkish, where its suffix table has no entry. This library writes the number. Reproducing a NaN is not parity with anything. -
Ukrainian declines the weekday name after a preposition, which moment selects with a pattern looking for bracketed text before the sequence. The tokenizer here does not record whether a literal came from brackets, so the standalone form is written.
Beyond the oracle, every embedded locale is written and read back in a test, so a name that cannot be parsed in the language it was written in is a failure rather than something nobody tried.
Go's time layouts
golayout is the other way of saying it, taken from Go, where the format
string is one particular time written the way you want yours written:
const text = try std.fmt.allocPrint(...); // or straight to a writer
try datetime.golayout.format(value, "2006-01-02T15:04:05Z07:00", writer);
const value = try datetime.golayout.parse("Jan _2 3:04PM", "Mar 15 2:30PM");
Go picked Mon Jan 2 15:04:05 MST 2006 as that time, so that every
component has a different number: month 1, day 2, hour 3 on the twelve
hour clock and 15 on the twenty-four hour one, minute 4, second 5, year 6,
and a zone seven hours west. Nothing is a code to look up; the layout is
an example. The layouts Go's own package names are here under the names it
gives them, so golayout.layout.rfc3339 and golayout.layout.kitchen
mean what they do there.
Go's behavior is the specification and zig build oracle-go checks it
against Go's own time package, formatting and parsing both. That
includes following Go where it is unhelpful: 2006 writes a year before
the common era with a leading minus and then will not read one back, and
MST writes a numeric offset when a reading has no zone name and then
will not read that back either, because Go takes the digits after a sign
as one number of hours and +0545 is not a count of hours. Both are
Go's, and a layout that means one thing there should not mean another
here.
CLDR patterns
cldr is the third way of saying it, and the one the rest of the world
is speaking: the pattern vocabulary the Unicode Consortium defines in
UTS #35, which ICU,
Java, .NET and every Intl.DateTimeFormat in a browser use underneath.
try datetime.cldr.format(value, "yyyy-MM-dd'T'HH:mm:ssXXX", .en, writer);
try datetime.cldr.format(value, "EEEE, d MMMM y", french, writer);
The letter says which field and how many of it says how that field is
written, so M is the month as a number, MMM abbreviates it, MMMM
spells it out and MMMMM reduces it to a letter. Text inside single
quotes is copied through, which is how a letter is written as itself, and
'' is one apostrophe. Every ASCII letter is reserved whether or not it
names a field, so hello is a compile error rather than four fields and
an o; quote it.
Where it goes beyond the other two is that the locale carries its own patterns. A caller that does not want to decide how a date is written asks for a length instead:
try datetime.cldr.formatDateTime(value, .medium, .short, locale, writer);
which is Mar 5, 2024, 2:30 PM in English, 5 mars 2024, 14:30 in
French and 2024/03/05 14:30 in Japanese, because the arrangement is
data rather than something this library decided. formatDate and
formatTime ask for one half.
There are two entry points because CLDR patterns arrive both ways.
format takes the pattern at comptime, tokenizes it while the program is
compiled, and refuses one that is not a pattern with a compile error;
formatRuntime takes one that was not known until the program ran, which
is what the locale's own patterns are. Both walk the same field writer.
Locales
-Dembed-cldr compiles in CLDR's own seven hundred and sixty-six
locales, and cldr.byName finds one by tag. Without it there is only
en, and nothing is fetched:
zig build -Dembed-cldr
zig build -Dembed-cldr -Dcldr-locales=fr,de,ja # or only the ones you want
A tag CLDR does not ship is retried with its last subtag dropped, so an
Accept-Language header works: en-US answers with en. That is not a
formality — CLDR ships no pt-BR, because Brazilian Portuguese is the
default content of pt.
The data is the Unicode Consortium's own, read out of its JSON
distribution by upstream/src/gen_cldr.js. The table it writes,
src/cldrlocales/all.zig, is committed and holds every locale CLDR ships,
so -Dembed-cldr compiles a file that is already here and fetches nothing,
and -Dcldr-locales narrows what that build compiles rather than what was
generated — an entry no locale is built from costs a binary nothing.
zig build gen-cldr regenerates the table when CLDR makes a release. It carries
what CLDR has and moment has no notion of: era names, quarter names, the
narrow width, the difference between the name inside a date and the name
standing alone, the flexible day periods that make B write "in the
morning", and the ten digits a locale writes its numbers with — a Bengali
date really is written in Bengali digits, rather than in ASCII ones
rewritten afterwards.
Compatibility, checked
ICU is the reference implementation of UTS #35, and zig build oracle-cldr diffs against it: every field at every count, the shapes
callers write, the corners of the quoting, and then every embedded locale
against the patterns a locale can differ about, at seven instants and
three offsets each.
889650 comparisons against ICU 78.3
124 locales CLDR ships and ICU 78.3 does not, unchecked
105 known and documented
no divergence beyond those
The oracle gives ICU a zone that is nothing but an offset, because that
is what a DateTime is, and a Gregorian calendar whose changeover has
been pushed before every date there is, because ICU's is Julian before
1582 and this library's is proleptic Gregorian throughout.
Four things are deliberately not ICU's behavior, and each is a limit rather than a bug:
-
V,VVandVVVare refused, along with the skeleton-onlyj,JandC. The first three name a zone — its short identifier, its long one, the city it is kept by — and aDateTimecarries an offset rather than a zone; ICU, given a zone that is nothing but an offset, answersunkandUnknown Location. The last three ask for whichever clock the locale prefers and are resolved before formatting begins; ICU writes nothing for them. A field that silently vanishes is worse than one that will not compile.VVVVis allowed, because its fallback is something an offset can truthfully say. -
A count a field has no meaning for is refused rather than falling back. ICU writes
MMMMMMas a six digit month number andOOas nothing at all. -
Fractional seconds keep their precision. ICU holds milliseconds and pads
SSSSonwards with zeros; this holds nanoseconds and writes them. For any value ICU can represent the two agree, which is why the oracle's corpus stops at the millisecond. The same choice, for the same reason, as the moment sequences make. -
Two locales are excused, and the oracle counts them. Hawaiian writes the month of its short date in lowercase Roman numerals, which CLDR expresses by hanging a numbering system on one field of a pattern; a numbering system here is ten digits and
romanlowis an algorithm. French as written in Mali has a joining pattern that CLDR's JSON and ICU inherit differently, and they disagree about a comma.
Beyond that, z and its neighbors take UTS #35's documented fallback:
given no zone to name, the localized GMT format, so z is GMT-5 and
zzzz is GMT-05:00, in the locale's own spelling and digits. The
abbreviation a zone did supply is on the value instead, put there by the
zone that knew it:
const local = zone.atTimestamp(1720000000);
std.debug.print("{s}\n", .{local.designation.slice()}); // CDT
CLDR patterns are formatting only. Parsing them is a separate and much
less well specified problem — several fields are ambiguous or
unparseable by construction — and the moment sequences, Go's layouts,
the strftime conversions, iso8601, rfc822 and rfc5322 are all
still there to read text with.
strftime conversions
strftime is the fourth way of saying it, and the one already written
down in most configuration files: a % followed by a letter, and
everything else copied through.
try datetime.strftime.format(value, "%Y-%m-%d %H:%M:%S", writer);
const value = try datetime.strftime.parseAll("%a, %d %b %Y %H:%M:%S %z", header);
Everything POSIX defines is here, along with the GNU extensions that
turn up beside it: %P, %k, %l, %s, %e, %C, %D, %F, %G,
%g, %h, %r, %R, %T, %u and %V. Between the % and the
letter go the GNU flags — - for no padding, _ for spaces, 0 for
zeros, ^ for upper case and # for the opposite case — then a minimum
field width, then the E or O modifier, which is read and dropped
because no locale here has an alternative representation to offer:
try datetime.strftime.format(value, "%A, %-d %B %Y", writer); // Friday, 5 January 2024
try datetime.strftime.format(value, "%^b %_3d", writer); // JAN 5
Two conversions are date(1)'s rather than strftime(3)'s, and both
are here because without them something this library holds could not be
written at all. %N is the nanosecond, nine digits by default and as
many as a width asks for, so %3N is milliseconds. %:z, %::z and
%:::z are the offset with colons in it, which is what RFC 3339 wants
and what %z cannot spell.
A conversion the library does not know is a compile error naming it,
where the C library would copy it through as text. That is the one place
a comptime format string earns its keep: %Q is a typo, and a typo that
prints itself is one nobody finds.
Parsing is strptime's shape rather than strftime's: whitespace in the
format matches any run of it including none, a numeric field takes one
digit as readily as two, names are matched without regard to case, and
text after what the format asked for is left alone. parse returns what
it read in Result.str; parseAll requires the whole input.
Compatibility, checked
glibc's C locale is the specification and zig build oracle-strftime
diffs against it: every conversion on its own, every flag and width over
the conversions they say anything about, the shapes callers write, and
each one read straight back. The oracle is C, compiled by Zig and linked
against whatever libc the host has, so it needs nothing added to the dev
shell.
12496 comparisons against glibc 2.42, no divergence beyond 626 known and documented
Those are the deliberate differences, and the oracle carries the same list so that anything else fails. Writing:
%shonors the offset. glibc reaches astruct tmthroughmktime, so its count of seconds is the fields read against the process's timezone andtm_gmtoffis ignored. ADateTimecarries the offset that says which instant it names, and uses it.%Zwrites nothing when the zone is not known, which POSIX allows in so many words. glibc falls back to the running process's zone name, which would be a claim about where a reading was made that nothing here can support.
Reading, glibc's strptime is followed as far as it goes and then four
things are kept that it throws away or refuses: %G with %V resolves
an ISO week date, %j names a date without a year beside it, %P is
read as well as written, and a flag, a width or an E/O modifier is
ignored rather than refused — so that a format string which writes a date
can read one back, which in glibc it cannot. %s reads a negative count
too, which glibc writes and will not read.
.NET format strings
dotnet is the vocabulary of C#'s DateTime.ToString and ParseExact,
and of PowerShell's Get-Date -Format. A string of one character is a
standard format, naming a pattern the culture supplies; anything longer
is a custom one, where letters repeat to ask for a width or a name:
try datetime.dotnet.format(value, "yyyy-MM-ddTHH:mm:ss.fffzzz", writer); // 2024-03-15T14:30:05.123-05:00
try datetime.dotnet.format(value, "dddd, MMMM d", writer); // Friday, March 15
try datetime.dotnet.format(value, "o", writer); // 2024-03-15T14:30:05.1230000-05:00
try datetime.dotnet.format(value, "R", writer); // Fri, 15 Mar 2024 19:30:05 GMT
Every standard format is here — d D f F g G m M o O r R s t T u U y Y
— and every custom specifier: d to dddd, M to MMMM, y to as
many ys as you like, h, H, m, s, f and F to seven digits,
t, tt, g, z to zzz, K, and the separators : and /. Quotes,
\ and % do what .NET makes them do, %d included, which is how a
custom specifier on its own is told from a standard format.
A Culture supplies the patterns the standard formats expand to and the
names, separators and meridiem a custom one writes. Two are built in:
Culture.invariant, the default, which is .NET's
CultureInfo.InvariantCulture; and Culture.en_us, which is what most
PowerShell sessions format in, with the patterns .NET 10 reads from ICU:
try datetime.dotnet.formatIn(value, "g", datetime.dotnet.Culture.en_us, writer); // 3/15/2024 2:30 PM
— where the space before PM is U+202F, as it has been in CLDR since
release 42. A culture is a comptime struct, so one of your own is a
literal.
Where .NET would throw a FormatException the library fails to compile:
an unterminated quote, a % at the end, eight fs, a single character
that names no standard format.
Parsing is ParseExact, exactly as strict. Every character of the format
has to be matched, spaces included, and all of the text used; a one letter
numeric field reads one or two digits and a longer one exactly as many as
it has letters; names and the meridiem ignore case. A field written twice
has to say the same thing both times, and a weekday has to be the date's
own:
const result = try datetime.dotnet.parse("ddd, dd MMM yyyy HH:mm:ss zzz", text);
if (!result.has_offset) {
// The text named no zone, which .NET records as DateTimeKind.Unspecified.
}
Result.has_offset says whether the text said where it was. When it did
not, the value's offset is the reference's — zero unless
Options.relative_to says otherwise — and a caller that needs to know
has to find out elsewhere. A date the text does not give comes from the
same place: .NET reads the clock there, and a library that took the clock
without being handed an Io would be reaching for globals behind your
back.
PowerShell's Get-Date
Get-Date has two parameters that take a format, and powershell has a
function for each. -Format is a .NET format string plus four names of
PowerShell's own, matched in any case:
try datetime.powershell.format(value, "FileDate", writer); // 20240315
try datetime.powershell.format(value, "FileDateTimeUniversal", writer); // 20240315T1930051234Z
try datetime.powershell.format(value, "dddd MM/dd/yyyy HH:mm K", writer);
-UFormat looks like strftime and is not quite it, because PowerShell
implements it by translating each conversion into a .NET format item of
its own choosing:
try datetime.powershell.uformat(value, "%A %m/%d/%Y %R %Z", writer); // Friday 03/15/2024 14:30 -05
The differences from the C library are PowerShell's, and so they are this
library's too. %Z is the offset in hours, not a zone name. %U and %W
are both the day of the year divided by seven, so the 1st of January is in
week 0 whichever day it falls on. %c puts the day before the month,
Fri 15 Mar 2024 14:30:05. %s is TotalSeconds rounded to fifteen
significant digits and then to a whole number, so half a second rounds up
and anything within half a second before the epoch is -0. A leading +
is dropped, and a brace has to be doubled, because PowerShell hands the
whole string to String.Format. strftime is the module for the C
library's meanings.
PowerShell reads neither vocabulary back, so the library supplies both
directions. powershell.parse reads a -Format string the way
ParseExact would. powershell.parseUFormat reads a -UFormat string
under rules of its own: every zero-padded number reads exactly its width,
the value is settled from the strongest thing the text gave — %s, then a
year with a month or day, then %j, then an ISO week — and everything
else the text said is checked against it. A %U that is another week or
a %p that is another half of the day is text that contradicts itself,
and is refused.
Compatibility, checked
.NET and PowerShell are the specification, and zig build oracle-powershell diffs against both: .NET formats the corpus and reads
back what this library wrote, in both cultures, and Get-Date itself is
run on every -Format name and -UFormat conversion, in four timezones.
pwsh comes from the dev shell, and the comparisons that need no cmdlet
are compiled from C# inside the script so that they take seconds.
50032 comparisons against PowerShell 7.6.5 on .NET 10.0.11, no divergence beyond 95 known and documented
.NET has two types a format string is applied to, and the library follows
DateTimeOffset, the one that carries an offset as a DateTime here does.
The deliberate differences are these, and the oracle carries the same list
so that anything else fails:
uandRconvert to UTC.Get-Datehands .NET a localDateTime, and .NET writesuandRof one without converting it, so theirZandGMTare false for anybody not in UTC.DateTimeOffsetconverts, and so does this.- A year .NET cannot hold is read when a field has the digits for it,
yyyyof0000oryyyyyof12345, because aDateTimehere can hold it. Written, such a year is .NET's arithmetic in a wider integer:-0005,12345. ochecks its offset's minutes. .NET's own reader ofodoes not, so it takes+05:99as 6:39.- A
-UFormatconversion PowerShell does not know is a compile error, where PowerShell writes the letter:%zcomes out asz. The same choicestrftimemakes, for the same reason.
The interchange formats have their own parsers, because the shape of their input is not known ahead of reading it and a format string cannot express that.
RFC 822, as used by email, HTTP, and RSS:
const result = try datetime.rfc822.parse("Fri, 21 Nov 1997 09:55:06 -0600");
const utc = result.value.toUtc();
It reads the syntax leniently, which is what a feed or a mailbox needs:
the day name, its comma and the seconds are all optional, two and three
digit years are windowed the way RFC 5322 section 4.3 says to, and the
alphabetic zones (GMT, EST, and the single letter military ones) are
accepted alongside ±hhmm.
RFC 5322, the current syntax of a message Date: header, is the same
grammar read strictly:
const result = try datetime.rfc5322.parse("Fri, 21 Nov 1997 09:55:06 -0600");
Strictly means the obsolete forms above are refused — a four digit year,
a two digit hour, a numeric zone and the comma after the day name are all
required, so 20 Jun 82 12:34 EST is error.ParseError here and a date
for rfc822.parse. The division is between forms that change what a date
means and forms that do not: a two digit year has to be guessed at a
century and an alphabetic zone is ambiguous between the handful RFC 822
named and the hundreds in use.
What it adds in exchange is the rest of RFC 5322, which rfc822 does not
have:
// Comments stand wherever whitespace may, they nest, and `\` quotes the
// character after it. A header still folded across lines is read as it
// arrived. All of it is part of the date, so `result.str` covers it.
const commented = try datetime.rfc5322.parse(
"Thu,\r\n 13 (the thirteenth) Feb 1969 23:32:54 -0330 (Newfoundland)",
);
// Section 3.3 gives `-0000` a meaning `+0000` does not: the time is UTC,
// but the sender would not say what zone it was in, so the date carries
// no zone information at all. Both leave `offset` zero, so the
// distinction is reported on its own.
const withheld = try datetime.rfc5322.parse("21 Nov 1997 09:55:06 -0000");
std.debug.print("{}\n", .{withheld.unknown_offset}); // true
Years past four digits are well formed — the grammar says 4*DIGIT —
and a date must be semantically valid as well as well formed, so a day
name that disagrees with the date it precedes is an error in both
parsers.
ISO 8601, including the RFC 3339 subset that most internet protocols mean when they say ISO 8601:
const result = try datetime.iso8601.parse("2024-03-15T14:30:00.5+05:30");
It reads all three date forms, in the extended spelling with separators and the basic one without, at whatever precision the input stops at:
| extended | basic | |
|---|---|---|
| calendar | 2024-03-15, 2024-03, 2024 |
20240315 |
| ordinal | 2024-075 |
2024075 |
| week | 2024-W11-5, 2024-W11 |
2024W115 |
A time follows a T, and only after a complete date: 2024-03T10:15
is refused, because ISO 8601 says "The date part of a date and time
expression shall be complete" and a time on no particular day means
nothing. Times may stop at the hour, minute, or second, and any of those
may carry a decimal fraction with either separator, so T14.5 is half
past two. 24:00 is the end of its date and comes back as midnight on the
next one. Zones are Z, ±hh, ±hh:mm, or ±hhmm, and come only after
a time: a zone after a bare date, 2024-03-15Z, is not read, and is left
as trailing text.
Two fields on the result carry what the string itself said. has_offset
distinguishes a local time that named no zone from one that ended in Z,
which offset alone cannot: both leave it zero. precision says which
component the input stopped at, so a caller can tell 2024-03 from
2024-03-01.
ISO 8601 forbids mixing the basic and extended forms, and so does this:
2024-03-15T143000 is error.MixedFormats, and so is an interval whose
two ends differ. The zone is the one deliberate exception, since +0530
after an extended time is common in real data. Durations and time
intervals have parsers of their own, below.
How closely this follows the standard
The text followed is ISO 8601-1:2019 with its Amendment 1:2022, and
ISO 8601-2:2019 with its Amendment 1:2025 for the few things Part 2 adds
that this reads. The doc comments cite the clause behind each rule, and
src/iso8601.zig opens with the full list. In summary:
| ISO 8601 | here | |
|---|---|---|
Dates, times, time shifts, 24:00 (Part 1, 5.2 to 5.4) |
read and written | |
| Durations, intervals, recurring intervals (5.5, 5.6) | read and written | |
A space for T; lower-case t, z, p |
shall not (3.2.1) | read, as RFC 3339 §5.6 allows |
+0530 after 14:30:00 |
one form throughout (5.4.3) | read |
-00:00 |
+ for zero (4.3.13) |
read, as RFC 3339 §4.3 allows |
2024-03-15T14, 2024-03-15T14.5 |
basic form only (5.3.1.3, 5.3.1.4) | read |
P1W2D |
W alone (5.5.2.2) |
read |
-P1D, P1M-1D |
Part 2, 4.4.1.9 and 14.2 | read and written |
Expanded years +002024 |
by agreement (4.4) | written, not read |
Decades 198, centuries 19 (5.2.2.2) |
allowed | not read |
A time with no date, T14:30 (5.3.1) |
allowed | not read |
P0003-06-04T12:30:05 (5.5.2.4) |
by agreement | not read |
P0.5M, P0.5Y, P0.5W (5.5.2.3) |
allowed | refused: no length without a date |
| A bare duration as an interval | by agreement (5.5.1, NOTE) | not read |
R0, R-1 |
not defined | refused |
Repeat rules /F2W (Part 2, 13) |
Part 2 | left as trailing text |
Durations
iso8601.parseDuration reads the other half of the syntax, and
Duration is what it reads into:
const result = try datetime.iso8601.parseDuration("P3Y6M4DT12H30M5S");
const later = now.add(result.value);
A Duration keeps months, days and everything below a day
apart, and that separation is the whole design. A duration is not a fixed
number of nanoseconds, because a month is not a fixed number of days: one
month after the 31st of January is the 28th of February, and a type that
reduced the duration to a count would have no way to say so. Instant is
for a fixed span; Duration is for the calendar's own arithmetic. Years
fold into months and weeks into days, since within each pair the
conversion is exact.
DateTime.add applies one, in the order XML Schema's Adding durations
to dateTimes
lays down: the sub-day part first, so that its whole days can be set
aside; then the months, with the day of the month clamped to the last
day of wherever it landed; then the days, as a day count. Doing the months
before the days is why adding a duration is neither commutative nor
associative — P1M1D from the 31st of January is the 1st of March, while
a day and then a month would be the 2nd — and why ISO 8601 writes the
components in that one order and no other.
ISO 8601-1 does not say how to add a duration to a date. ISO 8601-2 does,
in clause 14.4, and points to its Annex D for the method, and that annex
is informative and describes two. Its precedence method, one unit at a
time with truncation at each step (D.4.3), is the one above. Its
composite method (D.4.2) applies every component at once and carries
the excess afterwards, so a day that the duration moves past the end of
its month runs into the next: P1M1D from the 31st of January 2001 is the
32nd of February, which is the 4th of March. The two differ only
there. DateTime.addWith and Duration.addToDateWith take the method,
.xml_schema (the default, and what add uses) or .composite:
const jan31: datetime.DateTime = .{ .year = 2001, .month = .Jan, .day = 31 };
const d: datetime.Duration = .{ .months = 1, .days = 1 };
_ = jan31.add(d); // 2001-03-01
_ = jan31.addWith(d, .composite); // 2001-03-04
Neither models leap seconds, which Annex D.2.3 allows "for the accuracy of general purposes": a minute is always sixty seconds.
Two things about a duration are worth knowing before relying on it.
sign answers 1, -1, 0, or null when the fields disagree.
{ .months = 1, .days = -1 } is a real length of time, but ISO 8601-1
has at most one sign, in front of everything, and cannot write it. ISO
8601-2's composite durations put a sign on each component instead, and
that is what format writes for such a duration: P1M-1D, or
P-1Y-2M3D. parseDuration reads both forms back, but not a sign in
both places at once, -P1Y-2M, which Part 2 forbids. An interval refuses
any negative component, because a mixed duration runs forward from some
dates and backward from others: P1M-30D is a day earlier from the 31st
of January and a day later from the 1st of March. And
DurationParseResult.fractional says which component carried a decimal
fraction, because a caller may allow fewer of them than ISO 8601 does —
XML Schema's duration allows one only on the seconds, so P1.5D is a
good ISO 8601 duration and not a valid xs:duration, and once the
fraction is folded into nanoseconds there is nothing left to tell from.
Fractions of a year, a month or a week are refused outright rather than
guessed at, since none of the three has a length in days to divide. The
alternative P0003-06-04T12:30:05 spelling of a duration is not read.
Intervals
iso8601.parseInterval reads a time interval in any of ISO 8601's three
forms, and Interval is what it reads into:
const a = try datetime.iso8601.parseInterval("2007-03-01T13:00:00Z/2008-05-11T15:30:00Z");
const b = try datetime.iso8601.parseInterval("2007-03-01T13:00:00Z/P1Y2M10DT2H30M");
const c = try datetime.iso8601.parseInterval("P1Y2M10DT2H30M/2008-05-11T15:30:00Z");
if (b.value.contains(datetime.Instant.now(io))) { ... }
An Interval is a tagged union that keeps the form it was written in,
for the reason Duration keeps its months apart: 2001-01-31/P1M means a
month from the 31st of January, and the pair of endpoints it resolves to
would say only 28 days. start and end resolve whichever endpoint was
not written, by DateTime.add — or, for a duration and an end, by adding
the negated duration, since subtraction has no better definition once a
month has been clamped: P1M/2001-02-28 could have started on any of four
days in January, and it answers the 28th, the latest of them.
duration answers only when one was written, because which calendar
duration lies between two dates has no single answer, and length is the
fixed span in nanoseconds, taken between the endpoints' instants.
contains and overlaps treat an interval as half-open, holding its
start and not its end. ISO 8601 leaves that to the application, and it is
the choice that lets intervals tile: 2024-03-15/2024-03-16 and
2024-03-16/2024-03-17 share no instant and leave none out.
The parts are separated by a solidus, or by the -- that ISO 8601 allows
where a solidus cannot go. An end with no zone of its own is in the
start's, whether or not it is abbreviated: ISO 8601-1:2019/Amd 1:2022 says
a time shift written before the separator applies after it, and makes
2018-01-15T12:00:00+05:00/2018-02-20T12:00:00 end at +05:00. And the
end may leave out its higher-order components, which it takes from the
start:
| interval | ends at |
|---|---|
2007-12-14T13:30/15:30 |
15:30 the same day |
2008-02-15/03-14 |
2008-03-14 |
2008-02-15T09:00/16T17:00 |
17:00 the next day |
20071214T1330/1530 |
15:30 the same day |
The abbreviated end is read by laying it over the tail of the start's text
at each place one component ends and the next begins, and keeping the
splice that reads to the start's precision and consumes the most of the
end. Without separators there is nowhere to lay it, so a start in the
basic form can have its date left out and nothing finer. The result also
reports, for each endpoint that was a date, the has_offset and
precision that parse would have.
An interval has to run forward — an end before its start is
error.OutOfRange — and a duration in one may not carry a sign. A
duration that would carry the other endpoint outside the years a Year
can hold is refused at parse time, which is what makes start and end
safe on anything the parser returns; DateTime.addChecked is the same
check for a duration from somewhere else. A bare duration is not read,
since ISO 8601 counts it as an interval placed by context and there is none
of that here.
Interval.format writes one back in its own form, each endpoint in full in
the extended form with Z for a zero offset. A DateTime cannot say it
was read without a zone, so a local endpoint comes back as Z — the
instant length and contains took it to be.
Recurring intervals
iso8601.parseRecurringInterval reads a series of intervals, and
RecurringInterval is what it reads into: R, how many intervals, a
solidus, and an interval in any of the three forms above.
const result = try datetime.iso8601.parseRecurringInterval("R12/2024-01-31T09:00:00Z/P1M");
var occurrences = result.value.iterator();
while (try occurrences.next()) |occurrence| {
// occurrence is an Interval: occurrence.start(), occurrence.end(), ...
}
ISO 8601-1:2019 defines one as a "series of consecutive time intervals of
identical duration", and the word consecutive decides the
arithmetic: each interval starts where the one before it ended, so the
series is built by adding the duration to each occurrence in turn, not by
multiplying it from the first. Once a month has been clamped the two
differ. R/2024-01-31T00:00:00Z/P1M runs to the 29th of February, then the
29th of March, and stays on the 29th, because that is where each interval
ended. RFC 5545's RRULE multiplies instead, and lands on the 31st where
there is one, but it describes a pattern of events, not a run of intervals
laid end to end. An interval written as two endpoints has no calendar
duration to repeat, so its series repeats its length on the timeline.
The count is the number of intervals, the first one included — the
standard reads its own R15/… as "fifteen recurrences" — and R/ with no
count is unbounded. The form decides which occurrence the text names. A
start and an end, or a start and a duration, name the first, and the
series runs forward. A duration and an end name the last:
R/P1Y/1985-04-12T23:20:50Z is an unbounded run of years that ended in
April 1985. iterator walks outward from the one named, and isForward
says which way.
R0 and R-1 are refused. Neither ISO 8601-1:2019 nor ISO 8601-2:2019
defines them; an absent count is the only spelling of an unbounded series
either part gives. Accounts elsewhere say R-1 means unbounded, and
disagree about whether R0 is no intervals or one interval not repeated.
A count read the wrong way gives a series of the wrong length and nothing
to say so, so neither is guessed at. The repeat rule ISO 8601-2 adds after
the interval, as in R12/20150929T140000/P1H30M0S/F2W, is not read either:
it is left as trailing text.
Only the named occurrence is range-checked when the series is parsed,
because an unbounded series has no last occurrence to check. Walking one
into the edge of the years a Year can hold makes next return
error.OutOfRange. It does not return null there, because the series has
not ended; the next occurrence just cannot be represented.
JSON
Date, DateTime, Instant, Duration, Interval and
RecurringInterval carry the hooks
std.json looks for, jsonStringify, jsonParse and jsonParseFromValue,
so they can be fields of anything you read or write with it:
const Event = struct {
name: []const u8,
at: datetime.DateTime,
lasts: datetime.Duration,
};
const parsed = try std.json.parseFromSlice(Event, gpa,
\\{"name":"standup","at":"2024-03-15T09:00:00-05:00","lasts":"PT15M"}
, .{});
defer parsed.deinit();
const text = try std.json.Stringify.valueAlloc(gpa, parsed.value, .{});
Each is a JSON string holding its ISO 8601 spelling, not an object of
its fields, because that string is what every other JSON producer and
consumer of dates speaks — JavaScript's Date.prototype.toJSON writes one
— and this library reads it back:
| type | written as |
|---|---|
Date |
"2024-03-15" |
DateTime |
"2024-03-15T14:30:00-05:00", with Z for a zero offset |
Instant |
"2024-03-15T19:30:00Z", always in UTC |
Duration |
"P1Y2M10DT2H30M" |
Interval |
"2024-03-15T09:00:00Z/P1D", in the form it was built in |
RecurringInterval |
"R5/2024-03-15T09:00:00Z/P7D" |
A fraction of a second is written only when there is one. Reading goes through the ISO 8601 parsers above and has to consume the whole string, and it is strict: it refuses anything it would otherwise have to complete with something the text did not say.
- A
DateTime, anInstant, and every endpoint anIntervalor aRecurringIntervalwrites out have to be named to the second and carry an offset — the shape of RFC 3339'sdate-time, which is what JSON Schema'sdate-timeformat means."2024-03-15T14:30:00"is refused rather than read as UTC, and"2024-03-15T14:30Z"rather than given a:00. An interval's end may still leave its zone to the start, since ISO 8601 says the start's zone applies to it. - A
Daterefuses a time of day and a date that names no day, since it has nowhere to keep the one and nothing to hold for the other.
The parsers themselves stay lenient, and are the way to read a local or
reduced time on purpose: iso8601.parse reports has_offset and
precision beside the value, which a DateTime has no fields for.
A string that is not the representation is error.InvalidCharacter, and
one whose components are out of range — a month of 13, an interval that
runs backward — is error.Overflow. Those are the errors std.json
gives for a malformed and an oversized number, so they sit in the error
sets it already has. Anything but a string is error.UnexpectedToken.
Three things do not survive the trip. A DateTime's designation has
nowhere to go in ISO 8601 and comes back empty. An Instant is read by
removing the offset it was written with, and has nowhere to keep it, so a
field whose local offset matters — a forecast for a place — wants to be a
DateTime, which keeps it as written. And a Duration comes back
canonical, P14M as P1Y2M, the same length of time.
iso8601.writeDateTime and iso8601.writeDate are the writers the hooks
use, public for anyone who wants the same text without JSON around it.
The calendar arithmetic
A date is converted to and from a day number — a count of days from
1970-01-01 — by two of Ben Joffe's algorithms: A Very Fast 64-Bit Date
Algorithm from a day number to a
date, and the inverse from The Julian Map
the other way. The weekday is Howard Hinnant's weekday_from_days, from
chrono-Compatible Low-Level Date
Algorithms. They
are in src/Date.zig and src/dayofweek.zig, with the derivations in the
doc comments there.
Two of Hinnant's ideas do the work in all of them. The year is shifted to begin in March, which puts the leap day at the end of it and leaves month lengths that a single straight line steps over. And the calendar repeats exactly every 400 years — 146097 days, every era — so moving a date by a whole number of eras changes nothing about it, which is why one era being right means all of time is right. There is no table anywhere, and no leap-year test in the round trip itself.
From a day number to a date takes four multiplications and no division.
It keeps the March-based year but counts years backward from a far-future
leap day, which puts the long year of every four and the long century of
every four at the start of the count rather than the end, and it reads the
month and day off a single fixed-point line, correcting the quarter-day
drift that measuring years in units of 365.25 days leaves. From a date to
a day number shifts the year forward by whole eras so that everything is
unsigned, counts 365 days a year plus the leap days, and takes the days
before the month from Neri and Schneider's fixed-point line. They are
about three and a half and one and three-quarter times as fast as
Hinnant's civil_from_days and days_from_civil in zig build bench.
Where only the year and the first of January are wanted, which is how
posixtz places every switch of a rule, Date.yearAndFirstDay skips the
month and day altogether with Joffe's Faster Ordinal Date
Algorithms. It counts
January-based years forward from a year divisible by 400, which meets the
long year and the long century first just as counting backward does, and
reads the year and the day of the year from two multiplications. Whether
the year is one whose leap day the Gregorian calendar drops falls out of
the century step, from how far into its century the day is, so no
separate leap-year test is needed.
What that buys is a calendar exact in both directions for every year a
Year can hold, with no epoch-relative special cases and nothing that
degrades far from 1970. Year is an i32, numbered astronomically, so
there is a year 0 and negative years before it. Validity checking is not
part of any of the algorithms, so Date.isRegular and the assertions
inside the conversions are this library's own.
Joffe established the range of his algorithms by testing rather than
proof, so zig build verify-days checks them against Hinnant's, and
yearAndFirstDay against the full conversion, for every day number a Date can hold (see Testing), and the tests and
fuzz targets compare the two on every ordinary run. Hinnant's versions are
kept in src/hinnant.zig for that purpose and are not part of the API.
Hinnant's own verification, a sweep over every date of a span of years, is
kept too, and runs: see -Dbig-test-years below.
The time of day is split the way Joffe describes in A faster way to
convert a timestamp ➜ Hour, Min, Sec:
the whole minutes and the whole hours are both divided straight from the
second of the day, so the two divisions run at once, and the minute and
second follow by subtraction. That is in src/secondofday.zig.
Hinnant dedicates his algorithms to the public domain. Joffe's are written here from the descriptions in his articles rather than translated from his reference implementations. All of it is here under the same MIT license as the rest without any further condition.
A note on offsets
DateTime.offset is in seconds east of UTC, not minutes. Historical
offsets are not whole minutes: America/Chicago's local mean time, which
applies to any timestamp before 1883, is -5:50:36.
Keeping tzdata current
The IANA database is re-cut several times a year, usually because a
country has changed its rules at short notice, so the pin in
build.zig.zon goes stale on its own schedule rather than yours.
tools/update-tzdata.sh moves it. With no argument it takes the newest
release IANA publishes; pass a version to pin a particular one:
tools/update-tzdata.sh # newest published
tools/update-tzdata.sh 2026c # a specific release
The version lives in three places that have to agree — both dependency
hashes in build.zig.zon and the tz_release constant in build.zig,
which the generated data records as its own version — so the script
rewrites all three together and verifies afterwards that no reference to
the old release survived. It refuses to move backward, and checks that
both tarballs are actually published before touching anything, since
IANA's version endpoint has been known to move first.
It then regenerates build.zig.zon.nix with zon2nix, so it has to run
from nix develop. That file is where CI gets its Zig packages from (see
Testing), and left alone it would go on offering the old
release after the pin had moved.
.forgejo/workflows/tzdata.yml runs that daily. When a new release
appears it rebuilds zic from the new sources, regenerates the embedded
database, runs the suite against it, and only then opens a pull request.
Each release gets its own branch, tzdata-update-<version>. If that
branch already exists the release has been dealt with and the run stops
before building anything, so a pull request left open for review is never
disturbed. When a newer release turns up it arrives as its own branch and
its own pull request, and the earlier one is closed as superseded with a
comment pointing at its replacement. Nothing is force-pushed and no
branch is reused, so a review stays attached to the release it was
written about. Superseded branches are left in place as a record; only
their pull requests are closed.
See the comments at the top of the workflow for the runner and token it needs.
Testing
zig build test # system data only; embedded tests skip
zig build test -Dembed-tzdata # everything, including embedded-vs-system agreement
zig build test -Dno-system-tzdata # as though the machine had no database
zig build oracle # formatting against moment.js
zig build oracle-parse # parsing against moment.js, in both modes
zig build oracle-go # Go's time layouts against Go itself
zig build oracle-locale # the embedded locales against moment.js
zig build oracle-cldr # the CLDR patterns against ICU
zig build oracle-strftime # the strftime conversions against the C library
zig build oracle-powershell # the .NET format strings and Get-Date against .NET and PowerShell
zig build bench # always ReleaseFast, whatever -Doptimize says
zig build verify-days # every day number and date, Joffe against Hinnant
Every oracle is part of zig build test, so an ordinary run needs node,
go, ICU and pwsh, and fetches moment and CLDR the first time.
CI runs them without fetching anything. build.zig.zon.nix, generated by
zon2nix from both manifests, is a Nix expression for every package either
one names, and the flake exposes it as zig-deps: a directory laid out the
way zig build --system reads one. --system forbids Zig to fetch at all,
so a package the file is missing is an error naming it. Because the oracles
run as a second zig build in upstream/, which the first --system does
not reach, the directory is named to that one too, with -Dupstream-system;
given --system without it, the oracle steps refuse to run rather than
fetch what the outer build was forbidden to:
packages="$(nix build --no-link --print-out-paths .#zig-deps)"
zig build test --system "$packages" -Dupstream-system="$packages"
Regenerate the file whenever either manifest's dependencies change:
nix develop -c zon2nix --17 --nix=build.zig.zon.nix build.zig.zon upstream/build.zig.zon
They live in upstream/, which is a Zig project of its own with a manifest
of its own, and the steps above run it from here. The reason is what a
declared dependency costs: moment and the three CLDR packages are 143 MB,
and anything that reads a manifest rather than running a build — zon2nix,
and every Nix expression generated from it — reads every entry whether the
build ever calls for that package or not. Guarding the b.lazyDependency
call stops the fetch and not the declaration, so the entry has to live
somewhere a consumer's tooling does not read. Each oracle can also be run
from inside that directory, which is the same thing without the hop.
moment and CLDR are pinned there, because each is the specification being
tested against and a floating version would move the target; Go and ICU are
not, because their behavior is part of a toolchain rather than something to
fetch, and each oracle prints the version it ran against. PowerShell is
the same, and prints the .NET under it too. -Dcldr-locales
narrows the embedded table, which is the quick way to iterate on one locale.
The C++ in upstream/src/oracle_cldr.cpp is compiled by Zig rather than by
a toolchain of its own, so the dev shell needs ICU and pkg-config and
nothing more. The C in upstream/src/oracle_strftime.c is compiled the same
way and needs nothing at all beyond a libc, which is also why that one
step is the only oracle that does not join zig build test on Windows:
strptime, tm_gmtoff and tm_zone are not there to compare against.
src/fuzz.zig holds a property per parser: nothing crashes on input
nobody chose, whatever comes back holds together, and anything with an
inverse survives the round trip. Each runs twice over, against a list of
seeds and against inputs built by mutating them. Calendar arithmetic and
the calendar round trips take values rather than text, so those targets
draw dates and durations from a seeded generator instead, across every
year a Year holds and most often near its two ends. An ordinary test run
does a small amount of fuzzing and -Dfuzz-iterations=N does as much as
you like:
zig build test -Dfuzz-iterations=500000 --seed=42
For a long hunt, build in ReleaseSafe, which keeps every bounds and
overflow check but runs several times faster, and run the one binary under
many seeds at once. zig build test runs what it builds, so the first
seed is that run; the binary it leaves in .zig-cache/o/*/test (the
library's is the largest recently written in ReleaseSafe) then takes
--seed=0x… for each of the rest:
zig build test -Doptimize=ReleaseSafe -Dfuzz-iterations=10000000 --seed=0x1000
Sixteen seeds at ten million inputs a target take about half an hour on
sixteen cores. A failure prints the seed, and the input or drawn value it
failed on, and zig build test --seed=N replays it.
-Dbig-test-years=N sweeps every date from -N-01-01 to N-12-31
through the day-number conversions and back, checking that the day number
advances by exactly one, that the round trip returns the date it started
from, and that the weekday advances by one. It is Hinnant's own test of
the algorithms Date and DayOfWeek are built on, and it defaults to
zero, which skips it:
zig build test -Dbig-test-years=2000 # seconds
zig build test -Dbig-test-years=1000000 -Doptimize=ReleaseFast # the paper's own
The second is the span the paper publishes, 730,485,366 dates, and the
test asserts that count too — so a disagreement about how many days two
million years hold is itself a failure. It takes about fifteen seconds,
against the seventeen the paper reports for the same sweep in C++ in
2013. In Debug it takes long enough to be worth not doing.
The seed is the test runner's, so a failure replays exactly.
zig build verify-days converts every day number from Date.min_day_number to
Date.max_day_number — about 1.57 trillion, every day of every year a Year
holds — with both Joffe's algorithm and Hinnant's, converts each date back
to a day number, and stops at the first disagreement or the first date that
does not come back to where it started, or the first year or first of
January Date.yearAndFirstDay gets wrong. It is always ReleaseFast and uses
every processor, and is not part of zig build test because it takes about
fifty minutes on sixteen threads. Its last run, on 2026-10-16, found all
1,568,704,592,610 in agreement both ways, and yearAndFirstDay right on
every one.
Beside the mutation and generator targets are std.testing.fuzz ones,
which Zig's own coverage-guided fuzzer drives:
zig build test --fuzz # until interrupted, with a web interface
zig build test --fuzz=1M # a bounded run, then a report
The report names only the first fuzz test in the binary, but every one
of them ran and shares the coverage figure it gives. A finding prints
input saved to '.zig-cache/f/crash' above the report. The coverage comes
from LLVM's instrumentation, which is why build.zig compiles the test
binary with LLVM even in Debug; the self-hosted backend emits none, and the
fuzzer then has nothing to steer by. Outside --fuzz these targets see a
single empty input, from which a std.testing.Smith answers every range
with its minimum, so in an ordinary run the mutation and generator targets
are the ones doing the work.
-Dno-system-tzdata empties the directories the tests look in, which
makes a machine that has a timezone database behave like one that has
not. The tests that read it skip either way; the option is what makes the
skipped half reachable from either kind of machine, and it is a testing
knob rather than a build variant — the library behaves the same with and
without it.
Reading the docs locally
zig build docs-serve # http://127.0.0.1:8000, -Ddocs-port=N to change
A server rather than opening zig-out/docs/index.html, because the
viewer fetches sources.tar and main.wasm at runtime and a browser
refuses those from a file:// page.
References cited
The documents this library is written against.
- [POSIX] The Open Group and IEEE, The Open Group Base Specifications
Issue 8, IEEE Std 1003.1-2024,
https://pubs.opengroup.org/onlinepubs/9799919799/. The
strftimeandstrptimeconversion specifications thatstrftimeimplements, and theTZenvironment variable whose rule syntaxposixtzreads. - [GLIBC] Free Software Foundation, "Formatting Calendar Time", The GNU
C Library Reference Manual,
https://www.gnu.org/software/libc/manual/html_node/Formatting-Calendar-Time.html.
The extensions POSIX does not have —
%P,%k,%l,%s, the-,_,0,^and#flags and the field width — and, throughupstream/src/oracle_strftime.c, the behavior those are checked against. - [COREUTILS] Free Software Foundation, "date invocation", GNU Coreutils
Manual,
https://www.gnu.org/software/coreutils/manual/html_node/date-invocation.html.
%Nand the%:zfamily, which aredate(1)'s rather thanstrftime(3)'s and are the only way to write a nanosecond or an RFC 3339 offset. - [ISO8601] International Organization for Standardization, Date and
time — Representations for information interchange — Part 1: Basic rules,
ISO 8601-1:2019, https://www.iso.org/standard/70907.html. The calendar,
ordinal and week date forms that
iso8601reads, the duration syntaxDurationholds, and the time interval formsIntervalholds, clause by clause as the doc comments cite them. Clause 5.6 and definition 3.1.1.11 are whatRecurringIntervalfollows: a series of consecutive intervals, the count read as the number of intervals, and the duration-and-end form naming the last one rather than the first. - [ISO8601-2] International Organization for Standardization, Date and
time — Representations for information interchange — Part 2:
Extensions, ISO 8601-2:2019, https://www.iso.org/standard/70908.html.
The signs a duration may carry, in front (4.4.1.9) or on each component
(14.2); the four-digit negative year
-0001(4.4.1.2) thatiso8601.writeDateTimewrites; adding a duration to a date (14.4), and the two methods of its informative Annex D thatDuration.Arithmeticoffers; the repeat rules a recurring interval may carry in its clause 13, whichparseRecurringIntervalleaves unread; and, with Part 1, the evidence that the standard givesR0andR-1no meaning. - [ISO8601-1-AMD1] International Organization for Standardization, Date
and time — Representations for information interchange — Part 1: Basic
rules — Amendment 1: Technical corrections, ISO 8601-1:2019/Amd 1:2022,
https://www.iso.org/standard/81801.html. Its restated 5.5.1 is why an
interval's end without a zone takes the start's, written out in full or
not, and its 5.3.2 on the ending of the day is how
24:00is read: the first instant of the next day. - [ISO8601-2-AMD1] International Organization for Standardization, Date
and time — Representations for information interchange — Part 2:
Extensions — Amendment 1: Canonical expressions, extensions to time scale
components and date time arithmetic, ISO 8601-2:2019/Amd 1:2025,
https://www.iso.org/standard/86124.html. Which pairs of units convert
exactly — years and months, weeks and days, as
Durationfolds them — and which do not. - [ISO8601-1-WD] ISO/TC 154/WG 5, Data elements and interchange formats
— Information interchange — Representation of dates and times — Part 1:
Basic rules, ISO/WD 8601-1, working draft N0038, 16 February 2016,
https://www.loc.gov/standards/datetime/iso-tc154-wg5_n0038_iso_wd_8601-1_2016-02-16.pdf.
A draft of ISO 8601-1 that the Library of Congress made public, which is
what
RecurringIntervalwas first written from; the published text agrees with it on everything that type does. - [ISO8601-2-WD] ISO/TC 154/WG 5, Data elements and interchange formats
— Information interchange — Representation of dates and times — Part 2:
Extensions, ISO/WD 8601-2, working draft N0039, 16 February 2016,
https://www.loc.gov/standards/datetime/iso-tc154-wg5_n0039_iso_wd_8601-2_2016-02-16.pdf.
The draft of Part 2, public in the same place. Its repeat rules are
spelled differently from the published ones,
FREQ=where 2019 hasF. - [UTS35] Unicode Consortium, Unicode Locale Data Markup Language (LDML)
Part 4: Dates, UTS #35,
https://unicode.org/reports/tr35/tr35-dates.html. The pattern vocabulary
cldrspeaks, field by field and count by count. - [CLDR] Unicode Consortium, Unicode CLDR Project,
https://cldr.unicode.org/. The locale data
-Dembed-cldrcompiles in, andwindowsZones.xml, whichsrc/windowszones.zigis generated from. - [ICU] Unicode Consortium, "Formatting Dates and Times", ICU
Documentation,
https://unicode-org.github.io/icu/userguide/format_parse/datetime/. The
reference implementation of UTS #35, and what
zig build oracle-cldrdiffs against. - [MOMENT] Moment.js Documentation, https://momentjs.com/docs/. The
format-string sequences
DateTime.formatuses, the two parsing modes, and the locale data-Dembed-localescompiles in. - [GO] time package, Go Packages, https://pkg.go.dev/time. The
reference-time layouts
golayoutimplements, and whatzig build oracle-godiffs against. - [GET-DATE] Microsoft, "Get-Date (Microsoft.PowerShell.Utility)",
PowerShell 7.6 documentation,
https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/get-date?view=powershell-7.6.
The
-Formatand-UFormatparameterspowershellimplements, the fourFileDatenames, and the table of-UFormatspecifiers. - [GETDATECOMMAND] GetDateCommand.cs, PowerShell v7.6.0,
https://github.com/PowerShell/PowerShell/blob/v7.6.0/src/Microsoft.PowerShell.Commands.Utility/commands/utility/GetDateCommand.cs.
The translation of each
-UFormatconversion into a .NET format item, which is the specification where the documentation's table and the cmdlet disagree —%c,%Uand%Wamong them. - [DOTNET-CUSTOM] Microsoft, "Custom date and time format strings",
.NET documentation,
https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings.
The custom specifiers
dotnetimplements. - [DOTNET-STANDARD] Microsoft, "Standard date and time format strings", .NET documentation, https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings. The one-character standard formats and what each expands to.
- [DOTNET-RUNTIME] DateTimeFormat.cs and DateTimeParse.cs,
dotnet/runtime v10.0.0,
https://github.com/dotnet/runtime/tree/v10.0.0/src/libraries/System.Private.CoreLib/src/System/Globalization.
FormatCustomized,ParseByFormat,DoStrictParseand the two fast paths foroandr, whichdotnetfollows where the documentation is silent, and throughzig build oracle-powershellthe behavior it is checked against. - [TZDB] Internet Assigned Numbers Authority, Time Zone Database,
https://www.iana.org/time-zones. The zone data itself, and
zic, the reference compiler-Dembed-tzdatabuilds and runs. - [HINNANT] Hinnant, H., chrono-Compatible Low-Level Date Algorithms,
https://howardhinnant.github.io/date_algorithms.html.
days_from_civilandcivil_from_days, whichDateis checked against,weekday_from_days, whichDayOfWeekuses, and the sweep over every date in a span of years that-Dbig-test-yearsruns. - [JOFFE] Joffe, B., A Very Fast 64-Bit Date Algorithm: 30–40%
faster, 23 November 2025, https://www.benjoffe.com/fast-date-64. The
conversion from a day number to a date in
Date.fromDayNumber. - [JOFFE-MAP] Joffe, B., The Julian Map: A Faster technique for
Gregorian Date Conversion, 3 November 2025,
https://www.benjoffe.com/fast-date. The conversion from a date to a day
number in
Date.toDayNumber. - [JOFFE-LEAP] Joffe, B., The fastest "Is Leap Year" functions,
11 December 2025, updated 18 December 2025,
https://www.benjoffe.com/fast-leap-year. The survey and benchmarks of
leap-year tests that
leap.iswas chosen from. - [JOFFE-ORDINAL] Joffe, B., Faster Ordinal Date Algorithms, draft,
3 January 2026, https://www.benjoffe.com/fast-ordinal-date. The
conversion from a day number to a year and its first of January in
Date.yearAndFirstDay. - [JOFFE-WEEKDAY] Joffe, B., A faster way to calculate the day of the
week, 17 August 2026, https://www.benjoffe.com/fast-day-of-week. The
64-bit multiply-add-shift weekday that was measured against
DayOfWeek.fromDayNumberand not adopted. - [JOFFE-TIME] Joffe, B., A faster way to convert a timestamp ➜ Hour,
Min, Sec, 7 September 2026, https://www.benjoffe.com/fast-time-of-day.
The split of a second of the day into hour, minute and second that
Instant.asDateTimeandDateTime.toUtcuse. - [NERI-LEAP] Neri, C., libstdc++: Simplify year::is_leap(), GCC
commit 86a0df1a6c7f, 11 November 2023,
https://github.com/gcc-mirror/gcc/commit/86a0df1a6c7fe4a835620b868e76ea78d42d6620.
The divisibility test by 25 with masks of 15 and 3 that
leap.isuses, and the argument that it is exact. - [NERI] Neri, C. and L. Schneider, "Euclidean affine functions and
their application to calendar algorithms", Software: Practice and
Experience 53(4), 937–970, 2023, https://doi.org/10.1002/spe.3172.
The fixed-point divisions and the
bumpthat Joffe's algorithms build on, and the inverse thatDate.toDayNumberrefines. - [RFC822] Crocker, D., Standard for the Format of ARPA Internet Text
Messages, RFC 822, August 1982,
https://www.rfc-editor.org/info/rfc822. The date syntax
rfc822reads leniently, including the alphabetic and military zones. - [RFC1123] Braden, R., Ed., Requirements for Internet Hosts —
Application and Support, RFC 1123, October 1989,
https://www.rfc-editor.org/info/rfc1123. The four digit year that
amended RFC 822, and the layout Go names
RFC1123. - [RFC2822] Resnick, P., Ed., Internet Message Format, RFC 2822, April 2001, https://www.rfc-editor.org/info/rfc2822. The revision of RFC 822 that RFC 5322 in turn obsoletes.
- [RFC3339] Klyne, G. and C. Newman, Date and Time on the Internet:
Timestamps, RFC 3339, July 2002,
https://www.rfc-editor.org/info/rfc3339. The ISO 8601 profile most
internet protocols mean, which
iso8601accepts and whichstrftime.pattern.rfc_3339writes. - [RFC5322] Resnick, P., Ed., Internet Message Format, RFC 5322,
October 2008, https://www.rfc-editor.org/info/rfc5322. The current
date-timegrammarrfc5322reads strictly: comments, folding, and the meaning section 3.3 gives-0000. - [RFC8536] Olson, A., Eggert, P. and K. Murchison, The Time Zone
Information Format (TZif), RFC 8536, February 2019,
https://www.rfc-editor.org/info/rfc8536. The binary format
tzifreads, all three versions of it.