Architecture¶
Date math: Howard Hinnant's days_from_civil / civil_from_days¶
LOCAL-DATE converts between (year, month, day) and an epoch-day count
using Howard Hinnant's "chrono-Compatible Low-Level Date Algorithms". It is
correct across the entire proleptic
Gregorian calendar, not just the range the host Lisp's universal-time
happens to support, and every other date operation (PLUS-DAYS, ordering,
DAY-OF-WEEK) is defined in terms of it rather than reimplementing calendar
arithmetic separately.
Epoch day 0 is 1970-01-01, matching INSTANT's Unix epoch rather than CL's
native 1900-01-01 universal-time epoch.
The TZif reader¶
src/tzif.lisp parses the on-disk IANA time zone database format (RFC
8536) directly from bytes: the v1 32-bit-transition block, the v2/v3 header
and 64-bit-transition block that supersedes it when present, and the
POSIX-TZ string footer. Reading the compiled zoneinfo files that are
already on disk is what keeps cl-date-kit
dependency-free: see Compatibility for the trade-off
that comes with it.
Two details are easy to get backwards when implementing this:
- The POSIX-TZ offset sign is inverted from ISO 8601.
"JST-9"means UTC+9, because the POSIX convention is "hours subtracted from local time to reach UTC." Every offsetzone.lispparses out of a POSIX-TZ string is flipped to the usual UTC-relative sign immediately, in%PARSE-POSIX-NAME-OFFSET, so the rest of the codebase never has to think about the POSIX sign convention again. - The type "before" a transition is not the type "at" it. TZif stores,
for each transition, the type that becomes active starting at that
instant. The type in force just before transition
iis the type at transitioni-1(or a separate "initial type" before the very first transition) -- and gap/overlap detection needs both.
Detecting daylight-saving gaps and overlaps¶
%CLASSIFY-LOCAL-DATE-TIME in zone-local.lisp is the one function every
gap/overlap-sensitive operation (POSSIBLE-OFFSETS-FOR-LOCAL-DATE-TIME,
RESOLVE-LOCAL-DATE-TIME, and transitively ZONED-DATE-TIME-OF-LOCAL) goes
through, so there is exactly one implementation of the classification logic
to get right rather than two copies that could drift apart.
The technique: read a LOCAL-DATE-TIME's fields as if they directly were
epoch seconds (pretend the wall-clock reading is UTC). For each transition,
compute what that transition's boundary instant would read as under the
offset before it and under the offset after it:
- If the offset increases (spring forward) and the naive value falls between those two readings, it is in the gap -- it never happened.
- If the offset decreases (fall back) and the naive value falls between them, it is in the overlap -- it happened twice, once under each offset.
- Otherwise, resolve normally: guess an offset by treating the naive value as if it were already a real UTC instant, convert to a candidate UTC instant using that guess, then look up the offset that is actually in force at that instant. One such step is enough, because the gap/overlap check above has already ruled out the only cases where it could land on an inconsistent answer.
This applies the classification directly against TZif's transition table rather than against a higher-level rule object.
Continuation-passing style, used where it separates generation from selection¶
This library uses an explicit visitor/continuation argument -- a function the
callee invokes instead of allocating and returning a collection -- in two
situations, and stays with ordinary return values and MULTIPLE-VALUE-BIND
everywhere else. CPS is used only where it removes real complexity.
Streaming iteration over an unbounded or expensive-to-materialize
sequence. MAP-RRULE-OCCURRENCES, MAP-RRULE-SET-OCCURRENCES, and
MAP-LOCAL-DATE-INTERVAL take a callback and call it once per occurrence
(the DO-* macros are LOOP-style sugar over the same MAP-* functions).
An RRULE without COUNT/UNTIL can describe an infinite recurrence;
returning a list is not an option, and a lazy sequence abstraction would be
a second concept to learn on top of Lisp's own function-calling
convention. RRULE-CANDIDATES.LISP's internal candidate generators
(%RRULE-DATE-CANDIDATES, %RRULE-LOCAL-CANDIDATES) take an optional
visitor for the same reason at one layer down: with a visitor, a candidate
that BYSETPOS will discard is never consed into a throwaway list element.
Separating "what are the candidates" from "which one wins" in a search.
%SELECT-POSIX-ZONE-TRANSITION (zone.lisp) answers "the nearest POSIX-TZ
footer transition in DIRECTION from INSTANT." The two questions -- which
transitions are eligible, and which eligible transition is nearest -- used
to live in one nested loop that accumulated the answer through a mutable
SETF. Splitting the eligibility scan into a CPS producer,
%MAP-CANDIDATE-POSIX-ZONE-TRANSITIONS, that calls a visitor once per
eligible transition, and reducing over that visitor with a
direction-appropriate comparator closure in %SELECT-POSIX-ZONE-TRANSITION
itself, turns one function that did two things into two functions that
each do one -- the same shape as the streaming-iteration case, applied to
a search instead of an unbounded enumeration.
Sequencing dependent parse steps whose failure path already needed a
continuation. POSIX-TZ.LISP's POSIX-TZ grammar parser
(%PARSE-POSIX-NAME-OFFSET, %PARSE-POSIX-RULE-TIME, %PARSE-POSIX-RULE)
already took an INVALID continuation for malformed input, since the right
response to a grammar violation is to call ERROR from wherever the
violation is noticed, not to thread an error value back up through every
caller. Once failure was continuation-based, giving each parser an
ON-SUCCESS continuation alongside it -- rather than returning the parsed
value via VALUES -- let PARSE-POSIX-TZ-STRING chain the steps that
depend on each other's output as nested continuations: parse the zone
name/offset, and inside that success continuation parse the daylight
name/offset, and inside that parse the DST-start rule, and inside that parse
the DST-end rule, building the POSIX-TZ-RULE struct only once every step
has succeeded. It's the same "each step's continuation is the next step's
invocation" shape as the search-selection case above, applied to a chain of
dependent parses instead of a scan over a fixed collection.
Destination-passing, a close relative. The %WRITE-* functions spread
across ISO8601.LISP, ISO8601-DATE.LISP, and ISO8601-OFFSET.LISP
(%WRITE-LOCAL-DATE-TIME, %WRITE-LOCAL-DATE, %WRITE-ZONE-OFFSET, and so
on) take a STREAM argument and write to it rather than returning a
string, so a composite formatter like FORMAT-OFFSET-DATE-TIME -- itself
in ISO8601-OFFSET.LISP -- writes its date, time, and offset pieces,
sourced from across those files, directly onto one shared stream inside a
single WITH-OUTPUT-TO-STRING, instead of allocating and concatenating
three intermediate strings. PATTERN.LISP's %WRITE-PATTERN-FIELD follows
the same shape for the compiled pattern formatter.