@ 261102.1 · fetching

261008.1 · fd25c3e720385919273b5b776486d84af9aa3914

Parse the full ISO 8601 grammar in `fromdateiso8601` (#3617)

`fromdateiso8601` was `strptime("%Y-%m-%dT%H:%M:%SZ") | mktime`, which accepts only one of the many representations ISO 8601 permits. strptime cannot be extended to cover the rest portably, since it has no specifier for fractional seconds and glibc discards `%G` and `%V`.

Replace it with a parser in src/iso8601.c that handles basic and extended representations, ordinal and week dates, fractional seconds, offsets and date-only input. It uses integer arithmetic instead of `time_t`, so the results no longer depend on the platform.

This is stricter than before. Impossible dates like `2015-02-30`, which `mktime` used to normalize into March, and unpadded components like `2015-3-5T6:00:00Z` are now errors. So is any trailing input, including whitespace. Leading whitespace is rejected too; before, glibc skipped it and macOS did not. A leap second now maps to the second before it rather than the next minute. The error message has changed, and the result can now be non-integer.

reading declarations

Changes to src/iso8601.c. All files

src/iso8601.c

new file mode 100644@@ -0,0 +1,342 @@+/*+ * A self-contained ISO 8601 date and time parser.+ *+ * The C library is of no help here: strptime() has no format specifier for+ * fractional seconds, and no portable way to parse week dates (glibc+ * consumes %G and %V but then discards them).  The results would also+ * differ between the host strptime() and the copy bundled in util.c.+ * Everything below is plain integer arithmetic instead, which also keeps+ * the computation clear of the range of a 32-bit time_t.+ *+ * The accepted grammar is+ *+ *   datetime := date [ ("T"|"t"|" ") time ]+ *   date     := year "-" MM "-" DD           |  year MMDD+ *             | year "-" DDD                 |  year DDD+ *             | year "-" ("W"|"w") ww "-" d  |  year ("W"|"w") ww d+ *   year     := YYYY | ("+"|"-") YYYYYY+ *   time     := hms [ ("."|",") digits ] [ offset ]+ *   hms      := hh [ ":" mm [ ":" ss ] ]  |  hh [ mm [ ss ] ]+ *   offset   := ("Z"|"z") | ("+"|"-") hh [ [":"] mm ]+ *+ * As in the standard's own notation a run of letters stands for that many+ * decimal digits -- DDD is the three-digit day of the year, d the one-digit+ * day of the week -- while "digits" is one or more of them.+ *+ * The decimal fraction belongs to the lowest component of the hms that is+ * present, so "23,5" is half past 23 and "2320,8" is 23:20:48.+ *+ * The date must always be complete, so "2015-03" is an error even though it+ * is a valid ISO 8601 date.  The time may drop its seconds, or its minutes+ * and seconds, or be omitted altogether, in which case the value is+ * midnight UTC.  The date and the time must agree on whether separators are+ * used, as ISO 8601 requires -- "2026-08-28T093015Z" is not a valid+ * representation.  The offset is exempt from that rule, since "+0900" after+ * an extended time is common in the wild.+ */++#include <stdint.h>++#include "iso8601.h"++/*+ * Days since 1970-01-01 of the civil date y-m-d in the proleptic Gregorian+ * calendar, negative before the epoch, after Howard Hinnant's+ * days_from_civil().  Requires 1 <= m <= 12 and 1 <= d <= days_in_month(y, m);+ * the callers check both.+ */+static int64_t days_from_civil(int64_t y, int m, int d) {+  y -= m <= 2;+  int64_t era = (y >= 0 ? y : y - 399) / 400;+  int64_t yoe = y - era * 400;                                  /* [0, 399] */+  int64_t doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; /* [0, 365] */+  int64_t doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;          /* [0, 146096] */+  return era * 146097 + doe - 719468;+}++static int is_leap(int64_t y) {+  return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;+}++static int days_in_month(int64_t y, int m) {+  static const int d[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};+  return m == 2 && is_leap(y) ? 29 : d[m - 1];+}++/* ISO weekday (1 = Monday .. 7 = Sunday) of a day count since the epoch. */+static int iso_wday(int64_t days) {+  int64_t w = (days + 3) % 7;+  if (w < 0)+    w += 7;+  return (int)w + 1;+}++/*+ * An ISO year has 53 weeks if it starts on a Thursday, or if it is a leap+ * year starting on a Wednesday; otherwise it has 52.+ */+static int iso_weeks_in_year(int64_t y) {+  int jan1 = iso_wday(days_from_civil(y, 1, 1));+  return jan1 == 4 || (jan1 == 3 && is_leap(y)) ? 53 : 52;+}++static int civil_to_days(int64_t y, int mon, int mday, int64_t *days) {+  if (mon < 1 || mon > 12 || mday < 1 || mday > days_in_month(y, mon))+    return 0;+  *days = days_from_civil(y, mon, mday);+  return 1;+}++static int ordinal_to_days(int64_t y, int yday, int64_t *days) {+  if (yday < 1 || yday > (is_leap(y) ? 366 : 365))+    return 0;+  *days = days_from_civil(y, 1, 1) + yday - 1;+  return 1;+}++static int week_to_days(int64_t y, int week, int wday, int64_t *days) {+  if (week < 1 || week > iso_weeks_in_year(y) || wday < 1 || wday > 7)+    return 0;+  int64_t jan4 = days_from_civil(y, 1, 4);+  *days = jan4 - (iso_wday(jan4) - 1) + (int64_t)(week - 1) * 7 + (wday - 1);+  return 1;+}++/*+ * The scanner works over an explicit [p, end) range rather than a C string, so+ * that a NUL byte embedded in the input is a parse error instead of silently+ * truncating the value.+ */+typedef struct {+  const char *p;+  const char *end;+} iso8601_scanner;++static int scan_at_end(iso8601_scanner *s) {+  return s->p == s->end;+}++static int scan_peek(iso8601_scanner *s) {+  return scan_at_end(s) ? -1 : (unsigned char)*s->p;+}++static int scan_eat(iso8601_scanner *s, char c) {+  if (scan_at_end(s) || *s->p != c)+    return 0;+  s->p++;+  return 1;+}++/*+ * W, T and Z are what ISO 8601 calls designators.  They are written in upper+ * case, but the standard notes that "lower case characters may be used when+ * upper case characters are not available", so match either.+ */+static int scan_designator(iso8601_scanner *s, char c) {+  if (scan_at_end(s) || (*s->p != c && *s->p != c + ('a' - 'A')))+    return 0;+  s->p++;+  return 1;+}++static size_t scan_count_digits(iso8601_scanner *s) {+  size_t n = 0;+  while (s->p + n < s->end && s->p[n] >= '0' && s->p[n] <= '9')+    n++;+  return n;+}++static int scan_digits(iso8601_scanner *s, int n, int *out) {+  if (s->end - s->p < n)+    return 0;+  int v = 0;+  for (int i = 0; i < n; i++) {+    unsigned char c = (unsigned char)s->p[i];+    if (c < '0' || c > '9')+      return 0;+    v = v * 10 + (c - '0');+  }+  s->p += n;+  *out = v;+  return 1;+}++/*+ * A plain year is exactly four digits.  An expanded year carries a mandatory+ * sign and exactly six digits, which is what makes it unambiguous in front of+ * a date in the basic form.+ */+static int parse_year(iso8601_scanner *s, int64_t *year) {+  int64_t sign = 1;+  int digits = 4;+  if (scan_peek(s) == '+' || scan_peek(s) == '-') {+    sign = *s->p == '-' ? -1 : 1;+    s->p++;+    digits = 6;+  }+  if (s->end - s->p < digits)+    return 0;+  int64_t v = 0;+  for (int i = 0; i < digits; i++) {+    unsigned char c = (unsigned char)s->p[i];+    if (c < '0' || c > '9')+      return 0;+    v = v * 10 + (c - '0');+  }+  s->p += digits;+  *year = sign * v;+  return 1;+}++/* Sets *extended to whether the date was written with "-" separators. */+static int parse_date(iso8601_scanner *s, int64_t *days, int *extended) {+  int64_t year;+  int week, wday, mon, mday, yday;++  if (!parse_year(s, &year))+    return 0;++  if (scan_eat(s, '-')) {+    *extended = 1;+    if (scan_designator(s, 'W')) {+      return scan_digits(s, 2, &week) && scan_eat(s, '-') &&+             scan_digits(s, 1, &wday) && week_to_days(year, week, wday, days);+    }+    switch (scan_count_digits(s)) {+    case 3:+      return scan_digits(s, 3, &yday) && ordinal_to_days(year, yday, days);+    case 2:+      return scan_digits(s, 2, &mon) && scan_eat(s, '-') &&+             scan_digits(s, 2, &mday) && civil_to_days(year, mon, mday, days);+    default:+      return 0;+    }+  }++  *extended = 0;+  if (scan_designator(s, 'W')) {+    return scan_digits(s, 2, &week) && scan_digits(s, 1, &wday) &&+           week_to_days(year, week, wday, days);+  }+  switch (scan_count_digits(s)) {+  case 4:+    return scan_digits(s, 2, &mon) && scan_digits(s, 2, &mday) &&+           civil_to_days(year, mon, mday, days);+  case 3:+    return scan_digits(s, 3, &yday) && ordinal_to_days(year, yday, days);+  default:+    /* A bare "YYYYMM" is ambiguous with "YYMMDD"; the standard forbids it. */+    return 0;+  }+}++/*+ * Parses the time of day, storing the seconds since midnight in *secs.  A+ * time of "24:00:00" is midnight of the following day, which *carry reports.+ *+ * The minutes and seconds may be omitted, in which case the decimal fraction+ * belongs to the lowest component that is present -- "23,5" is half past 23+ * -- so *unit* records how many seconds that component is worth.+ */+static int parse_time(iso8601_scanner *s, int extended, double *secs,+                      int64_t *carry) {+  int hh, mm = 0, ss = 0, unit = 3600;++  if (!scan_digits(s, 2, &hh))+    return 0;+  if (extended ? scan_eat(s, ':') : scan_count_digits(s) >= 2) {+    if (!scan_digits(s, 2, &mm))+      return 0;+    unit = 60;+    if (extended ? scan_eat(s, ':') : scan_count_digits(s) >= 2) {+      if (!scan_digits(s, 2, &ss))+        return 0;+      unit = 1;+    }+  }++  double frac = 0;+  if (scan_peek(s) == '.' || scan_peek(s) == ',') {+    static const double pow10[] = {+      1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7,+      1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14, 1e15+    };+    s->p++;+    if (scan_count_digits(s) == 0)+      return 0;+    uint64_t num = 0;+    int digits = 0;+    while (scan_peek(s) >= '0' && scan_peek(s) <= '9') {+      if (digits < 15) {+        num = num * 10 + (uint64_t)(*s->p - '0');+        digits++;+      }+      s->p++;+    }+    frac = (double)num / pow10[digits];+  }++  *carry = 0;+  if (hh == 24) {+    if (mm != 0 || ss != 0 || frac != 0)+      return 0;+    hh = 0;+    *carry = 1;+  } else if (hh > 24 || mm > 59 || ss > 60) {+    return 0;+  } else if (ss == 60) {+    ss = 59;+  }++  *secs = hh * 3600 + mm * 60 + ss + frac * unit;+  return 1;+}++/* An absent offset means UTC, which is what jq has always assumed. */+static int parse_offset(iso8601_scanner *s, int *offset) {+  if (scan_designator(s, 'Z')) {+    *offset = 0;+    return 1;+  }++  int sign = scan_peek(s);+  if (sign != '+' && sign != '-') {+    *offset = 0;+    return 1;+  }++  s->p++;+  int oh, om = 0;+  if (!scan_digits(s, 2, &oh))+    return 0;+  int had_colon = scan_eat(s, ':');+  if (!scan_digits(s, 2, &om) && had_colon)+    return 0;+  if (oh > 23 || om > 59)+    return 0;++  *offset = (sign == '+' ? 1 : -1) * (oh * 3600 + om * 60);+  return 1;+}++int iso8601_parse(const char *buf, size_t len, double *out) {+  iso8601_scanner s = {buf, buf + len};+  int64_t days, carry = 0;+  int extended, offset = 0, sep;+  double secs = 0;++  if (!parse_date(&s, &days, &extended))+    return 0;++  sep = scan_peek(&s);+  if (sep == 'T' || sep == 't' || sep == ' ') {+    s.p++;+    if (!parse_time(&s, extended, &secs, &carry) || !parse_offset(&s, &offset))+      return 0;+  }+  if (!scan_at_end(&s))+    return 0;++  *out = (double)(days + carry) * 86400.0 + secs - offset;+  return 1;+}