Skip to content

File dp_isotime.h

File List > inc > dp_isotime.h

Go to the documentation of this file

#ifndef DP_ISOTIME_H
#define DP_ISOTIME_H

/* clock_gettime + gmtime_r are POSIX, and the feature-test macro that
   exposes them is on the COMPILE LINE -- see the top-level CMakeLists.txt,
   which puts it on the exported target too, so a downstream inherits it.
   This header used to raise _POSIX_C_SOURCE itself, which is a no-op for any
   translation unit that reached libc first (doppler#986); the comment on it
   also claimed doppler "builds as strict c99", and it does not --
   CMAKE_C_EXTENSIONS defaults ON, so the dialect is gnu99. */

#include <stdint.h>
#include <stdio.h>
#include <time.h>

#ifdef __cplusplus
extern "C"
{
#endif

#define DP_ISOTIME_MAX 32

#define DP_ISOTIME_SEC 0u
#define DP_ISOTIME_MSEC 3u
#define DP_ISOTIME_USEC 6u
#define DP_ISOTIME_NSEC 9u

#define DP_ISOTIME_BASIC 0
#define DP_ISOTIME_EXTENDED 1

  static inline int
  dp_isotime_format_as (char *buf, size_t cap, int64_t sec, uint32_t nsec,
                        unsigned frac, int style)
  {
    if (!buf || nsec > 999999999u)
      return -1;
    if (frac != DP_ISOTIME_SEC && frac != DP_ISOTIME_MSEC
        && frac != DP_ISOTIME_USEC && frac != DP_ISOTIME_NSEC)
      return -1;
    if (style != DP_ISOTIME_BASIC && style != DP_ISOTIME_EXTENDED)
      return -1;

    time_t    t = (time_t) sec;
    struct tm tm_utc;
    if (!gmtime_r (&t, &tm_utc))
      return -1;

    /* Basic "YYYYMMDDThhmmss" is 15 chars and carries no colons; extended
       "YYYY-MM-DDThh:mm:ss" is 19 and does. Only the separators differ, so
       the calendar break-down above is done once for both. */
    char date[24];
    if (strftime (date, sizeof date,
                  style == DP_ISOTIME_EXTENDED ? "%Y-%m-%dT%H:%M:%S"
                                               : "%Y%m%dT%H%M%S",
                  &tm_utc)
        == 0)
      return -1;

    if (frac == DP_ISOTIME_SEC)
      {
        int n = snprintf (buf, cap, "%sZ", date);
        return (n < 0 || (size_t) n >= cap) ? -1 : n;
      }

    /* TRUNCATE. `nsec / 10^(9-frac)` is integer division, so .999888777
       becomes .999 at millisecond precision rather than carrying to 1.000
       and stepping the seconds field. Never use a rounding conversion. */
    uint32_t scale = 1u;
    for (unsigned i = frac; i < DP_ISOTIME_NSEC; i++)
      scale *= 10u;

    int n = snprintf (buf, cap, "%s.%0*luZ", date, (int) frac,
                      (unsigned long) (nsec / scale));
    return (n < 0 || (size_t) n >= cap) ? -1 : n;
  }

  static inline int
  dp_isotime_format (char *buf, size_t cap, int64_t sec, uint32_t nsec,
                     unsigned frac)
  {
    return dp_isotime_format_as (buf, cap, sec, nsec, frac,
                                 DP_ISOTIME_BASIC);
  }

  static inline int
  dp_isotime_now (char *buf, size_t cap, unsigned frac)
  {
    struct timespec ts;
    if (clock_gettime (CLOCK_REALTIME, &ts) != 0)
      return -1;
    return dp_isotime_format (buf, cap, (int64_t) ts.tv_sec,
                              (uint32_t) ts.tv_nsec, frac);
  }


  /* ── parsing ───────────────────────────────────────────────────────────
   *
   * The inverse of the formatter above, in this file for the reason the two
   * spellings share one formatter: a parser elsewhere is a second opinion
   * about what this library's own timestamps mean.
   */

  static inline int
  dp_isotime_digits_ (const char **p, int n, int *out)
  {
    int v = 0;
    for (int i = 0; i < n; i++)
      {
        char c = (*p)[i];
        if (c < '0' || c > '9')
          return -1;
        v = v * 10 + (c - '0');
      }
    *p += n;
    *out = v;
    return 0;
  }

  static inline int64_t
  dp_isotime_days_from_civil_ (int64_t y, unsigned m, unsigned d)
  {
    y -= (m <= 2);
    const int64_t  era = (y >= 0 ? y : y - 399) / 400;
    const unsigned yoe = (unsigned)(y - era * 400);
    const unsigned doy
        = (153u * (m + (m > 2u ? (unsigned)-3 : 9u)) + 2u) / 5u + d - 1u;
    const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy;
    return era * 146097 + (int64_t)doe - 719468;
  }

  static inline int
  dp_isotime_parse (const char *s, int64_t *sec, uint32_t *nsec)
  {
    if (!s || !sec || !nsec)
      return -1;
    const char *p = s;
    int         Y, Mo, D, h, mi, se;
    /* The shortest legal input is the basic form with no fraction,
       `YYYYMMDDThhmmssZ` -- 16 characters. Checked BEFORE s[4] is read,
       because a shorter string makes that read run off the end of the
       caller's buffer rather than merely reaching a NUL: measured by ASan on
       `dp_isotime_parse ("")`, which is one of this parser's own reject
       cases. */
    for (int i = 0; i < 16; i++)
      if (s[i] == '\0')
        return -1;
    /* One format, read two ways: the separators are present or they are not,
       and s[4] is where they first differ. Mixed spellings are rejected by
       the very checks that skip the separators. */
    const int ext = (s[4] == '-');
    if (dp_isotime_digits_ (&p, 4, &Y) != 0)
      return -1;
    if (ext && *p++ != '-')
      return -1;
    if (dp_isotime_digits_ (&p, 2, &Mo) != 0)
      return -1;
    if (ext && *p++ != '-')
      return -1;
    if (dp_isotime_digits_ (&p, 2, &D) != 0)
      return -1;
    if (*p != 'T' && *p != 't' && *p != ' ')
      return -1;
    p++;
    if (dp_isotime_digits_ (&p, 2, &h) != 0)
      return -1;
    if (ext && *p++ != ':')
      return -1;
    if (dp_isotime_digits_ (&p, 2, &mi) != 0)
      return -1;
    if (ext && *p++ != ':')
      return -1;
    if (dp_isotime_digits_ (&p, 2, &se) != 0)
      return -1;
    /* A leap second is spelled :60 and is a real instant on the wire; every
       other field is range-checked so a transposed one cannot slide through
       as a plausible date. */
    if (Mo < 1 || Mo > 12 || D < 1 || D > 31 || h > 23 || mi > 59 || se > 60)
      return -1;

    uint32_t frac = 0;
    if (*p == '.' || *p == ',')
      {
        p++;
        int digits = 0;
        if (*p < '0' || *p > '9')
          return -1;
        /* Scaled to nanoseconds as it is read, so `.5` is 500000000 and not
           5: the field is a FRACTION, not an integer count of anything. */
        for (; *p >= '0' && *p <= '9'; p++, digits++)
          if (digits < 9)
            frac = frac * 10u + (uint32_t)(*p - '0');
        for (int i = digits; i < 9; i++)
          frac *= 10u;
      }

    int64_t offset = 0;
    if (*p == 'Z' || *p == 'z')
      p++;
    else if (*p == '+' || *p == '-')
      {
        const int neg = (*p == '-');
        p++;
        int oh, om = 0;
        if (dp_isotime_digits_ (&p, 2, &oh) != 0)
          return -1;
        if (*p == ':')
          p++;
        if (*p >= '0' && *p <= '9' && dp_isotime_digits_ (&p, 2, &om) != 0)
          return -1;
        if (oh > 23 || om > 59)
          return -1;
        offset = (int64_t)oh * 3600 + (int64_t)om * 60;
        if (neg)
          offset = -offset;
      }
    else
      return -1; /* no zone: see the note above -- refused, never assumed */
    if (*p != '\0')
      return -1;

    const int64_t days
        = dp_isotime_days_from_civil_ ((int64_t)Y, (unsigned)Mo, (unsigned)D);
    *sec  = days * 86400 + (int64_t)h * 3600 + (int64_t)mi * 60 + se - offset;
    *nsec = frac;
    return 0;
  }

#ifdef __cplusplus
}
#endif

#endif /* DP_ISOTIME_H */