sysl

Module

sysl.time

The calendar: a date and a time of day with no zone attached, and the arithmetic that turns one into the other.

Import import sysl.time

The calendar: a date and a time of day with no zone attached, and the arithmetic that turns one into the other.

A local date-time is a reading on a wall clock. It is not a point on the timeline and it does not become one until somebody says where the wall is. Kept apart from Instant it is an ordinary, boring value: a day number and a count of microseconds since midnight, both plain integers with no calendar hidden inside.

A day number, not packed civil fields. The alternative — year, month, day, hour and minute as bit fields inside one word — is the design most people reach for, and the arithmetic says no. Packing to the nanosecond needs 30 bits for the nanosecond, 17 for the time of day and 9 for the day and month, which is 56, leaving 8 bits of year: a range of ±128 years, which is not a calendar. Dropping to microseconds leaves 18 bits of year and does fit. But a count fits more comfortably at the same width — microseconds within a day need 37 bits, leaving 27 for the day number and a range of ±183,000 years — and it costs nothing to compute with, because comparing, subtracting and adding a length of time are one instruction each on a count and an unpack-recompute-repack on packed fields. Only “what month is it” wants the fields, and that is the rare question, paid for below where it is asked.

Index

us_per_day us_per_hour us_per_milli us_per_minute us_per_second at_offset civil_from_days date_at date_text datetime_at datetime_text day_of day_of_year days days_from_civil days_in_month floor_div floor_mod from_offset hour_of hours instant_text leap_year micros millis minute_of minutes month_name month_of odd_hours odd_minutes odd_seconds odd_us offset_text on_or_after parse_date parse_datetime parse_offset parse_time parse_timestamp plus_days plus_months plus_years resolve second_of seconds since time_at time_text timestamp_text us_of wall_of wall_us weekday weekday_name whole_days whole_hours whole_micros whole_millis whole_minutes whole_seconds year_of Civil Duration Instant LocalDate LocalDateTime LocalTime Offset Resolution TimeParseError Weekday DurationUnits Add for Duration Add for Instant Display for Instant Display for LocalDate Display for LocalDateTime Display for LocalTime Display for Offset Display for Resolution Display for TimeParseError DurationUnits for T Eq for Duration Eq for Instant Eq for LocalDate Eq for LocalDateTime Eq for LocalTime Eq for Offset Mul for Duration Neg for Duration Ord for Duration Ord for Instant Ord for LocalDate Ord for LocalDateTime Ord for LocalTime Ord for Offset Sub for Duration Sub for Instant Sub for Instant

Constants

us_per_day

const us_per_day: long = 86400000000

us_per_hour

const us_per_hour: long = 3600000000

us_per_milli

const us_per_milli: long = 1000

us_per_minute

const us_per_minute: long = 60000000

us_per_second

const us_per_second: long = 1000000

Functions

at_offset

at_offset(t: Instant, o: Offset) -> LocalDateTime

An instant read against a wall set a fixed distance from UTC, and back. Both directions are total, which is what separates a fixed offset from a zone: a zone whose clocks move answers the second question with none or two, and this pair never does, because an offset is a number rather than a rule with a history.

That is also why they are here and a zone is not. A timestamp on a wire, in a log or in a database column carries the offset that was in force when it was written — the sender resolved the zone already, and what arrived is the record of that decision. Reading one back needs no table, no update cadence and no filesystem, which is the whole of what makes it the library’s business.

civil_from_days

civil_from_days(d: LocalDate) -> Civil

date_at

date_at(y: int, m: int, d: int) -> LocalDate

date_text

date_text(d: LocalDate) -> string

datetime_at

datetime_at(y: int, mo: int, d: int, h: int, mi: int, s: int) -> LocalDateTime

datetime_text

datetime_text(ldt: LocalDateTime) -> string

day_of

day_of(d: LocalDate) -> int

day_of_year

day_of_year(d: LocalDate) -> int

The day of the year, from 1 on the first of January. It is a subtraction rather than a sum over the months, because the day numbers are already what the subtraction needs.

days

days(n: long) -> Duration

days_from_civil

days_from_civil(c: Civil) -> LocalDate

Howard Hinnant’s civil-from-days pair, which is the standard way to do this and is worth using rather than rediscovering. It works by shifting the epoch to the start of a 400-year era, after which every quantity in it is non-negative and the leap rules become one exact division apiece.

days_from_civil is its own inverse over the whole int range, which is the property to assert rather than any particular date.

days_in_month

days_in_month(y: int, m: int) -> int

How many days a month has, which is the one calendar question that cannot be answered by counting.

floor_div

floor_div(a: long, b: long) -> long

Division that rounds toward minus infinity, which is the only kind a calendar can use: the day containing an instant is floor(us / us_per_day), and a / that truncates toward zero puts every moment of the day before the epoch into the day after it.

floor_mod

floor_mod(a: long, b: long) -> long

from_offset

from_offset(ldt: LocalDateTime, o: Offset) -> Instant

hour_of

hour_of(t: LocalTime) -> int

hours

hours(n: long) -> Duration

instant_text

instant_text(t: Instant) -> string

An instant written down, which is the one rendering here that needs no zone: an offset of zero is what the count is measured from rather than a conversion applied to it, so this reports the value rather than interpreting it. Z says which, in the spelling ISO 8601 gives that offset.

It is the module’s because coherence makes it the module’s alone. Display belongs to the library and so does Instant, so no program naming neither of them may write an impl for the pair — and a type nothing may render is a type that renders nowhere at all.

leap_year

leap_year(y: int) -> bool

Whether a year has a 29th of February, by the rule the proleptic Gregorian calendar states: every fourth year, except every hundredth, except every four-hundredth.

micros

micros(n: long) -> Duration

The constructors, from the shortest length this representation can name to the longest it should.

micros is the identity, and is here anyway: the representation being microseconds is a fact of this module rather than of the caller, and a program writing Duration(n) has reached past the constructors to depend on it. The two short ones are also the only two an embedded program asks for – a blink loop wants 120 milliseconds, and there is no way to spell that in seconds.

millis

millis(n: long) -> Duration

minute_of

minute_of(t: LocalTime) -> int

minutes

minutes(n: long) -> Duration

month_name

month_name(m: int) -> string

month_of

month_of(d: LocalDate) -> int

odd_hours

odd_hours(d: Duration) -> long

…and the remainders, which are what the parts above leave. odd_minutes of three and a half hours is thirty, not two hundred and ten.

odd_minutes

odd_minutes(d: Duration) -> long

odd_seconds

odd_seconds(d: Duration) -> long

odd_us

odd_us(d: Duration) -> long

offset_text

offset_text(o: Offset) -> string

+05:30, the way an offset is written everywhere one is written down — a sign, then a width the reader can rely on, which is what makes a rendered timestamp sortable as text. Z is UTC, and it is a spelling rather than a special case: an offset of zero is written that way by ISO 8601 and read back as zero by offset_of below.

on_or_after

on_or_after(d: LocalDate, w: Weekday) -> LocalDate

The first date on or after d falling on the given weekday, which is what “every Tuesday” means once a rule has been given a starting point.

parse_date

parse_date(s: string) -> Result[LocalDate, TimeParseError]

YYYY-MM-DD. The year is four digits, which is what ISO 8601 says and what date_text writes; a year outside 0..9999 has no ISO spelling without a sign prefix, and that extension is not here.

parse_datetime

parse_datetime(s: string) -> Result[LocalDateTime, TimeParseError]

A date and a time, joined by a space or a T. Both spellings are read because both are written: ISO 8601 says T, and datetime_text writes a space because that is what a person reads.

parse_offset

parse_offset(s: string) -> Result[Offset, TimeParseError]

Z, +05:30, -08:00. The Z is zero, which is the spelling offset_text writes it back as.

parse_time

parse_time(s: string) -> Result[LocalTime, TimeParseError]

HH:MM, HH:MM:SS, or HH:MM:SS.ffffff — the seconds and the fraction each optional, on the same terms time_text writes them.

parse_timestamp

parse_timestamp(s: string) -> Result[Instant, TimeParseError]

A whole timestamp, straight to the point on the timeline it names — which is the thing a program reading a log line, a wire format or a database column actually wants, and the one conversion in this direction that needs no zone table.

It reads more shapes than timestamp_text writes, deliberately. The renderer emits one form because a wire format with options is a wire format everybody implements differently; the parser takes the space as well as the T, and a time with no seconds as well as one with them, because what arrives was written by somebody else. Liberal in what it accepts, strict in what it sends.

plus_days

plus_days(ldt: LocalDateTime, n: int) -> LocalDateTime

Adding days to a wall clock reading is not adding a length of time to it. Seven days later is the same clock face seven rows down the calendar, whatever the timeline did in between — and across a change of a zone’s clocks the two answers differ by an hour. Both are right; they answer different questions, which is why they are spelled differently.

plus_months

plus_months(d: LocalDate, n: int) -> LocalDate

Months are added by the calendar rather than by counting days, so the day of the month is clamped where the target month is shorter: the 31st of January plus one month is the 28th or 29th of February. Every library that offers this makes that choice, and clamping is the one that keeps “the last day of the month” landing on a last day.

plus_years

plus_years(d: LocalDate, n: int) -> LocalDate

resolve

resolve(ldt: LocalDateTime, offset_at: Instant -> Offset) -> Resolution

A wall clock reading resolved against a zone, where a zone is anything able to say what its offset was at a given instant.

The zone arrives as a function rather than as a type, which is what keeps this file free of a capability. The whole of what resolution needs to know about a zone is that one question, and a host reading localtime_r, a package talking to an RTC chip and a table written out by hand can all answer it. sysl.posix.time.from_local is the host’s answer wired to this.

How it decides, and the one assumption in it

A reading taken as though the offset were zero is within fourteen hours of the instant it names, because that is the widest any zone is ever set from UTC. So the offsets a day either side of that guess bracket every transition that could bear on the answer, and there are at most two of them to try.

Each candidate is applied and then checked against itself: an offset that is still in force at the instant it produces is a real answer, and one that is not is the reading being read with an offset nobody’s clock was showing. One survivor is Unique, two is Ambiguous, and none is Gap – the reading fell in the hour the clocks skipped, so no offset can produce it.

The assumption is that a zone does not change its offset twice inside forty-eight hours. No zone in the IANA database ever has. One that did would be resolved against the wrong pair of offsets rather than diagnosed, which is worth writing down because nothing here could detect it.

second_of

second_of(t: LocalTime) -> int

seconds

seconds(n: long) -> Duration

since

since(later: Instant, earlier: Instant) -> Duration

The same difference under a name that says which end is which. later - earlier is right and earlier - later is just as easy to write, so the named form stays for the call that wants to be read rather than worked out.

time_at

time_at(h: int, m: int, s: int) -> LocalTime

time_text

time_text(t: LocalTime) -> string

Seconds appear only when there are any. A meeting at half past nine is written 09:30 by every human being who has ever written one down, and a renderer insisting on 09:30:00 is reporting its representation rather than its value. The microseconds appear on the same terms, because a renderer that shows a value to the second and drops what is below it is reporting something the value does not say.

timestamp_text

timestamp_text(t: Instant, o: Offset) -> string

A timestamp as a machine reads it: 2026-03-08T09:30:00-05:00, or …Z where the offset is zero.

This one is written for RFC 3339 and the renderers above are written for people, which is why the shapes differ where it would have been easy to share one. The seconds are always present, even at zero, because RFC 3339 requires them — so datetime_text‘s rule of dropping what a value does not say is exactly wrong here, where the reader is a parser with a grammar rather than somebody glancing at a meeting time. The date and the time are joined by T rather than a space for the same reason, and the offset is written flush against the time rather than after a space.

The fraction still appears only when there is one, because RFC 3339 makes it optional and a .000000 on every timestamp is six characters of noise on the wire.

us_of

us_of(t: LocalTime) -> long

wall_of

wall_of(us: long) -> LocalDateTime

wall_us

wall_us(ldt: LocalDateTime) -> long

A wall clock reading as a single count, measured from the same origin as an Instant but not an instant: it is what the count would be if the zone’s offset happened to be zero. A conversion through a zone starts here and then asks what the offset actually is.

weekday

weekday(d: LocalDate) -> Weekday

1970-01-01 was a Thursday, so shifting by four puts Sunday at zero. Nothing here needs to know which day a week starts on; it only needs the seven in the order the world uses them.

A weekday is a simple enum, so it converts to its number and back, and both directions are used: the arithmetic wants a number to take a remainder of, and everything else wants a name. The remainder is in range by construction, which is what makes the conversion back total rather than something that has to be checked.

weekday_name

weekday_name(w: Weekday) -> string

The day’s name, which is the variant’s own name and so is not written twice. Weekday::Image answers a simple enum’s variant with the word it is spelled with (reference/attributes.md § A simple enum), so this function is the library’s name for the attribute rather than a table that could drift from the declaration above it. month_name below is the other kind and stays a table: a month is an int here, and “January” is a word nothing in the source is already holding.

whole_days

whole_days(d: Duration) -> long

The parts a duration is read back in, each truncated toward zero. They are how a length of time is stated to somebody who has to check it: “167 hours” is a sentence a reader can hold against a calendar, and 601200000000 is not.

whole_hours

whole_hours(d: Duration) -> long

whole_micros

whole_micros(d: Duration) -> long

whole_millis

whole_millis(d: Duration) -> long

whole_minutes

whole_minutes(d: Duration) -> long

whole_seconds

whole_seconds(d: Duration) -> long

year_of

year_of(d: LocalDate) -> int

Types

Civil

struct Civil
    year: int
    month: int
    day: int

Proleptic Gregorian, which means the Gregorian rules run backwards through the years before anybody agreed to them. That is a decision and not an oversight: the alternative is a calendar with a ten-day hole in it whose position depends on which country you ask.

Duration

struct Duration
    us: long

A length of timeline. It is not a number of days, because a day is not a fixed length of timeline — a day that a zone’s clocks move through is 23 or 25 hours long.

Instant

struct Instant
    us: long

A point on the timeline, counted from 1970-01-01T00:00:00Z. It names no place and no calendar: the same Instant is a different wall clock reading in every zone, which is the entire reason it is a separate type from the civil ones.

LocalDate

struct LocalDate
    day: int

Days since 1970-01-01. Signed, so dates before the epoch are ordinary values rather than a special case — the algorithm below never branches on the sign.

LocalDateTime

struct LocalDateTime
    date: LocalDate
    time: LocalTime

A wall clock reading. Two independent counts rather than one, because they are added to and compared far more often than they are combined.

LocalTime

struct LocalTime
    us: long

Microseconds since midnight, from zero up to but not including a whole day.

Offset

struct Offset
    minutes: int

How far a zone’s wall clock is set from UTC, in whole minutes. Minutes rather than hours because India is at +05:30 and Nepal at +05:45, and a library that assumes whole hours works everywhere its author has lived.

Resolution

enum Resolution
    Unique(at: Instant)
    Ambiguous(earlier: Instant, later: Instant)
    Gap(before: Offset, after: Offset)

What a wall clock reading turns out to name, once a zone has been asked.

A reading is usually one instant, sometimes none, and sometimes two. That is a property of zones rather than a shortcoming here: where a zone sets its clocks forward, the hour it skips never happens, and where it sets them back, the hour it repeats happens twice. Every date-time library either says so in a return type or picks one silently, and the host’s own answer is the silent kind – mktime given the repeated reading answers the second occurrence and reports nothing, and given the skipped one it rewrites the caller’s fields and answers a time nobody asked for.

Gap carries the offsets either side of the transition rather than an instant, because there is no instant to carry. What to do about it is a policy question – push the reading forward by the size of the gap, clamp it to the transition, or refuse it – and from_offset(ldt, before) is the first of those, which is the one most libraries choose.

TimeParseError

enum TimeParseError
    BadShape(at: usize)
    OutOfRange(what: string)
    Trailing(at: usize)

Why a parse refused, at the granularity a caller can act on.

The three are separated by what a caller would do: a shape error is a message about the format, a range error is a message about the value, and a trailing error usually means the text held something more that the caller meant to split off first.

Weekday

enum Weekday
    Sunday
    Monday
    Tuesday
    Wednesday
    Thursday
    Friday
    Saturday

Traits

DurationUnits

trait DurationUnits
    us -> Duration
    ms -> Duration
    s -> Duration
    minutes -> Duration
    hours -> Duration
    days -> Duration

Durations written the way a datasheet writes them: the number first and the unit after it, so sleep(5.ms) and join(ssid, pw, auth, 20.s) say at a glance what millis(5) and seconds(20) say after a moment’s reading.

The short units are symbols and the long ones are words, which is a split about where each is used rather than a compromise between two styles. A timeout, a poll interval and a debounce are the sub-second end, they are written constantly, and ms is the spelling every datasheet already uses. At the other end the number is small and the line is not dense, so 30.days costs nothing and says more than 30.d would. 5.min is deliberately absent: it would sit beside min(a, b) and int::Min meaning something else entirely in each position.

propertythe constructor it mirrors
5.usmicros(5)
5.msmillis(5)
5.sseconds(5)
5.minutesminutes(5)
5.hourshours(5)
5.daysdays(5)

The free constructors stay, and the two spellings are not a duplication to be tidied away: a duration built from a computed value reads better as millis(n) than as n.millis, and the constructors are what library/ and sysl-lang/pico2 are written against.

There is no matching set for Instant. A point on the timeline has no natural 5.<unit> — five of what, from when? — so the only way to name one is still to say which epoch it is counted from.

MemberSignatureDescription
usus -> Duration
msms -> Duration
ss -> Duration
minutesminutes -> Duration
hourshours -> Duration
daysdays -> Duration

Implementations

Add for Duration

impl Add for Duration

Add for Instant

impl Add[Duration] for Instant

An instant moved by a length of time. This is the absolute answer: it lands exactly that many microseconds along the timeline whatever any calendar does in between.

Display for Instant

impl Display for Instant

Display for LocalDate

impl Display for LocalDate

Display for LocalDateTime

impl Display for LocalDateTime

Display for LocalTime

impl Display for LocalTime

Display for Offset

impl Display for Offset

Display for Resolution

impl Display for Resolution

The rendering, so that a resolution can be reported without matching on it.

Display for TimeParseError

impl Display for TimeParseError

DurationUnits for T

impl[T: Integer] DurationUnits for T

One block over the whole integer family, which is what Display and Hash already do for the same reason: u12 and i5 are types a program may name, so no finite list of blocks could cover them, and a bare literal’s int is only one member of the family it has to cover.

The widening to long is where the representation is met — a Duration counts microseconds in a long — and it is what lets a u8 receiver work at all.

Eq for Duration

impl Eq for Duration

Eq for Instant

impl Eq for Instant

Eq for LocalDate

impl Eq for LocalDate

Eq for LocalDateTime

impl Eq for LocalDateTime

Eq for LocalTime

impl Eq for LocalTime

Eq for Offset

impl Eq for Offset

Mul for Duration

impl Mul[long] for Duration

Scaling reads one way round only: d * 2 is writable and 2 * d is not, because an operator’s implementation is written for the type on its left and nothing may be written for long.

Neg for Duration

impl Neg for Duration

Ord for Duration

impl Ord for Duration

Ord for Instant

impl Ord for Instant

Ord for LocalDate

impl Ord for LocalDate

Ord for LocalDateTime

impl Ord for LocalDateTime

Ord for LocalTime

impl Ord for LocalTime

Ord for Offset

impl Ord for Offset

Sub for Duration

impl Sub for Duration

Sub for Instant

impl Sub[Duration] for Instant

Sub for Instant

impl Sub[Instant, Duration] for Instant

The difference of two instants is a duration, and it is the operator. An implementation carries its result as Out (reference/expressions.md § The operand and the result are both trait arguments), so one type may subtract two different things and answer with a third: the row above lands further along the timeline, and this one measures the gap between two points on it. What tells them apart is the type of the right operand and nothing else, which is what makes both of them ordinary uses of -.

Search

Esc
to navigate to open Esc to close