orib.dev: Tmdate

Plan 9's date and time formatting APIs sucked. It was a subset of the unix date and time APIs, which also suck. It was only aware of two timezones: local and UTC. And, unlike Unix, we had neither strftime, nor strftime. Instead, we had a bunch of buggy and quirky time parsers scattered through the source tree.

We've had a crappy home-rolled date parser in seconds(1), more than one in the upas mail server source tree to deal with mail formats in emails, another one in the http server, and a bunch more hidden under rugs and in dusty corners.

And on IRC, I've been trying to help users that are trying to compute times in one timezone and move them to another. Our APIs may as well not have existed.

So I fixed it.

I aimed to write a library that is adequate, without being complicated. Flexible date and time formatting were critical. It also needs to be simple to compute dates such as "tomorrow at this time", as well as "24 hours from now". Becasue of timezones transitions, the two are not the same.

The manpage is here

Summary

The API that I ended up settling on is this:

	#include <u.h>
	#include <libc.h>

	typedef struct Tm    Tm;
	typedef struct Tmfmt Tmfmt;
	typedef struct Tzone Tzone;

	struct Tm {
		int  nsec;     /* nanoseconds (range 0..1e9) */
		int  sec;      /* seconds (range 0..59) */
		int  min;      /* minutes (0..59) */
		int  hour;     /* hours (0..23) */
		int  mday;     /* day of the month (1..31) */
		int  mon;      /* month of the year (0..11) */
		int  year;     /* C.E year - 1900 */
		int  wday;     /* day of week (0..6, Sunday = 0) */
		int  yday;     /* day of year (0..365) */
		char zone[];   /* time zone name */
		int  tzoff;    /* time   zone delta from GMT, seconds */
		Tzone *tz;     /* time zone */
	};

	Tzone *tzload(char *name);
	Tm    *tmnow(Tm *tm, char *tz);
	Tm    *tmtime(Tm *tm, vlong abs, Tzone *tz);
	Tm    *tmtimens(Tm *tm, vlong abs, int ns, Tzone *tz);
	Tm    *tmparse(Tm *dst, char *fmt, char *tm, Tzone *zone, char **ep);
	vlong  tmnorm(Tm *tm);
	Tmfmt  tmfmt(Tm *tm, char *fmt);
	void   tmfmtinstall(void);

This API largely maintains interoperability with the legacy time APIs. No new types are introduced. Instead, Tm is augmented to support the new functionality.

The largest amount of effort went into the design of the time parsing API

Timezones

One of the largest deficiencies with the old date and time APIs was their complete ignorance of timezones. They had the concept of local time, and of GMT. There was no way to handle any other timezones. Most software in the system hard coded tables of timezone names and their offsets. These were out of date.

The new APIs add functionality for loading timezones by name. These timezones can be passed to the various time manipulation functions in order to operate on them, allowing for correct handling of timezones.

Timezones are an opaque type, and the only way to get the offset out of them is by computing a time with them. This turns out to be all we really need them for. So far, it looks like getting timezone offsets in isolation is simply unnecessary.

Parsing and formatting

The most useful new functions are the parsing and formatting APIs. These are designed for flexible time handling, sufficient to replace all hand-rolled date parsing code through the whole OS.

Formatting is done using the fmt family of APIs, such as print, dofmt, and the usual machinery around that. The format character used for time formatting is τ. Yes, that's the greek letter tau -- you can input it by pressing alt-*-t in sequence. The tmfmt function is used to produce a Tmfmt struct that will format a date according to a format string.

In the intial versions of the API, I looked to Go for guidance, with its magic date to determine formatting. However, as I looked more closely at the implementation, I found that I didn't like it very much.

In addition to the difficulty in remembering which magic numbers mapped to which date fields, I realized that it was more difficult than necessary to parse the format string when there were no spaces between date components.

Jan2020

is something that I would like to be able to generate.

So, on a recomemendation, I looked at moment.js. While the API they provide is not a good fit for Plan 9, their format strings are top notch. I didn't take it directly, but the inspiration is clear.

The format string that is implemented in 9front is below:

For example, this program computes the date one week from now, ignoring any time zone translations.

Y, YY, YYYY
Represents the year. YY prints the year in 2 digit form.
M, MM, MMM, MMMM
The month of the year, in unpadded numeric, padded numeric, short name, or long name, respectively.
D, DD
The day of month in unpadded or padded numeric form, respectively.
W, WW, WWW
The day of week in numeric, short or long name form, respectively.
h, hh
The hour in unpadded or padded form, respectively
m, mm
The minute in unpadded or padded form, respectively
s, ss
The second in unpadded or padded form, respectively
t,tt
The milliseconds (or, thousandths of seconds)in unpadded and padded form, respectively.
u, uu, uuu, uuuu
The microseconds in unpadded. padded form modulo milliseconds, or unpadded, padded forms of the complete value, respectively. n, nn, nnn, nnnn, nnnnn, nnnnnn The nanoseconds in unpadded and padded form modulo milliseconds, the unpadded and padded form modulo microseconds, and the unpadded and padded complete value, respectively.
Z, ZZ, ZZZ
The timezone in [+-]HHMM and [+-]HH:MM, and named form, respectively. If the named timezone matches the name of the local zone, then the local timezone will be used. Otherwise, we will attempt to use the named zones listed in RFC5322.
a, A
Lower and uppercase 'am' and 'pm' specifiers, respectively.
[...]
Quoted text, copied directly to the output.
_
When formatting, this inserts padding into the date format. The padded width of a field is the sum of format and specifier characters combined. When For example, __h will format to a width of 3. When parsing, this acts as whitespace.
?
When parsing, all formats of the following argument are tried from most to least specific. For example, ?M will match January, Jan, 01, and 1, in that order. When formatting, ? is ignored.

Computations

Computations were the next large feature to design. In many date and time libraries, there is a large API surface area dedicated to this. In the Plan 9 time library, the decision was the best API was tiny.

For computing dates at an absolute offset in the future, it's simplest to just compute the number of seconds, and add it to the Unix time offset, constructing a date from this. Because unix time is an absolute time, with no adjustments, this just works.

Below is an example program that computes a day 1 week in the future, with no time zone adjustments:

	#include <u.h>
	#include <libc.h>

	#define Fmt "?WW, ?DD ?MMM ?YYYY ?hh:?mm:?ss ?Z"
	void
	main(void)
	{
		Tm tm;
		vlong t;
		Tzone *tz;

		tmfmtinstall();
		if(argc != 2)
			sysfatal("usage: %s date\n");
		if((tz = tzload("local")) == nil)
			sysfatal("load timezone: %r");
		if(tmparse(&tm, argv[1], fmt, tz) == nil)
			sysfatal("parse date: %r");
		t = tmnorm(&tm);
		t += 7*24*3600;
		tmtime(&tm, t, tm->tz);
		print("%τ", tmfmt(&tm, Fmt));
	}

But often, you want to be able to say "Set an alarm for 4 AM tomorrow". Adding an absolute offset doesn't work in this case, so we need a way to add adjusted offsets.

This is done with the tmnorm API. Tmnorm does two things: It computes the absolute unix time of a Tm structure, and it wraps out of range values. So, for example, Jan 32nd becomes Feb 1st.

This means that you can simply add your offsets to the Tm structure, normalize it, and you have a timezone-adjusted offset into the future. Here's an example like the previous one, but with DST and timezone adjustments:

	#include <u.h>
	#include <libc.h>

	#define Fmt "?WW, ?DD ?MMM ?YYYY ?hh:?mm:?ss ?Z"
	void
	main(void)
	{
		Tm tm;
		vlong t;

		tmfmtinstall();
		if(argc != 2)
			sysfatal("usage: %s date\n");
		if((tz = tzload("local")) == nil)
			sysfatal("load timezone: %r");
		if(tmparse(&tm, argv[1], fmt, local) == nil)
			sysfatal("parse date: %r");
		tm.mday += 7;
		tmnorm(&tm);
		print("%τ", tmfmt(&tm, Fmt));
	}

Summary

There's not much to this API. In the end, the goal of a minimalist, easy to understand, easy to implement date and time parsing API seems to be achieved, and the amount of code dedicated to parsing dates has shrunk quite a bit.