From 7b85b9d54ebe770582d507673c41c3d05e4d9e4f Mon Sep 17 00:00:00 2001 From: Alexei Znamensky Date: Sun, 11 Oct 2026 01:38:44 +0000 Subject: [PATCH 1/3] lib: add strftime() and strptime() functions Add strftime(format, [time], [utc]) to format a point in time according to a strftime(3) format string. The time may be given as epoch value or as TimeSpec dictionary and defaults to the current time. It is interpreted in the local timezone unless the utc argument is truthy. strftime("%Y-%m-%d %H:%M:%S", 1647953502, true); -> "2022-03-22 12:51:42" Add strptime(input, format) as inverse operation, parsing a string according to a strptime(3) format string into a TimeSpec dictionary suitable for timelocal() and timegm(). Fields not covered by the format default to 1900-01-01 00:00:00, isdst defaults to -1 and wday/yday are always computed from the parsed date since not all libc implementations fill them. The entire input must be consumed by the format, except for trailing whitespace. timegm(strptime("22/Mar/2022:12:51:42", "%d/%b/%Y:%H:%M:%S")); -> 1647953502 Factor out TimeSpec <-> struct tm conversion helpers shared with localtime(), gmtime(), timelocal() and timegm(). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Alexei Znamensky --- include/ucode/internal/lib.h | 4 +- lib.c | 265 ++++++++++++++++++++++++++--- tests/custom/03_stdlib/70_strftime | 72 ++++++++ tests/custom/03_stdlib/71_strptime | 82 +++++++++ 4 files changed, 401 insertions(+), 22 deletions(-) create mode 100644 tests/custom/03_stdlib/70_strftime create mode 100644 tests/custom/03_stdlib/71_strptime diff --git a/include/ucode/internal/lib.h b/include/ucode/internal/lib.h index 85e4b495..d1a7a9b3 100644 --- a/include/ucode/internal/lib.h +++ b/include/ucode/internal/lib.h @@ -109,9 +109,11 @@ typedef enum { UC_FN_LOADFILE = 71, UC_FN_CALL = 72, UC_FN_SIGNAL = 73, + UC_FN_STRFTIME = 74, + UC_FN_STRPTIME = 75, /* Number of slots; keeps the table and this enum in step, see lib.c. */ - UC_FN_COUNT = 74 + UC_FN_COUNT = 76 } uc_stdlib_fn_t; __hidden extern const uc_function_list_t uc_stdlib_functions[]; diff --git a/lib.c b/lib.c index 508c4cd1..88107799 100644 --- a/lib.c +++ b/lib.c @@ -5321,16 +5321,9 @@ uc_uniq(uc_vm_t *vm, size_t nargs) * @property {number} isdst - Daylight saving time in effect (yes = 1) */ static uc_value_t * -uc_gettime_common(uc_vm_t *vm, size_t nargs, bool local) +uc_tm_to_timespec(uc_vm_t *vm, const struct tm *tm) { - uc_value_t *ts = uc_fn_arg(0), *res; - time_t t = ts ? (time_t)ucv_to_integer(ts) : time(NULL); - struct tm *tm = (local ? localtime : gmtime)(&t); - - if (!tm) - return NULL; - - res = ucv_object_new(vm); + uc_value_t *res = ucv_object_new(vm); ucv_object_add(res, "sec", ucv_int64_new(tm->tm_sec)); ucv_object_add(res, "min", ucv_int64_new(tm->tm_min)); @@ -5345,6 +5338,19 @@ uc_gettime_common(uc_vm_t *vm, size_t nargs, bool local) return res; } +static uc_value_t * +uc_gettime_common(uc_vm_t *vm, size_t nargs, bool local) +{ + uc_value_t *ts = uc_fn_arg(0); + time_t t = ts ? (time_t)ucv_to_integer(ts) : time(NULL); + struct tm *tm = (local ? localtime : gmtime)(&t); + + if (!tm) + return NULL; + + return uc_tm_to_timespec(vm, tm); +} + /** * Return the given epoch timestamp (or now, if omitted) as a dictionary * containing broken-down date and time information according to the local @@ -5417,8 +5423,8 @@ uc_gmtime(uc_vm_t *vm, size_t nargs) return uc_gettime_common(vm, nargs, false); } -static uc_value_t * -uc_mktime_common(uc_vm_t *vm, size_t nargs, bool local) +static bool +uc_timespec_to_tm(uc_value_t *to, struct tm *tm) { #define FIELD(name, required) \ { #name, required, offsetof(struct tm, tm_##name) } @@ -5437,29 +5443,41 @@ uc_mktime_common(uc_vm_t *vm, size_t nargs, bool local) FIELD(isdst, false) }; - uc_value_t *to = uc_fn_arg(0), *v; - struct tm tm = { 0 }; + uc_value_t *v; bool exists; - time_t t; size_t i; if (ucv_type(to) != UC_OBJECT) - return NULL; + return false; + + memset(tm, 0, sizeof(*tm)); for (i = 0; i < ARRAY_SIZE(fields); i++) { v = ucv_object_get(to, fields[i].name, &exists); if (!exists && fields[i].required) - return NULL; + return false; - *(int *)((char *)&tm + fields[i].off) = (int)ucv_to_integer(v); + *(int *)((char *)tm + fields[i].off) = (int)ucv_to_integer(v); } - if (tm.tm_mon > 0) - tm.tm_mon--; + if (tm->tm_mon > 0) + tm->tm_mon--; + + if (tm->tm_year >= 1900) + tm->tm_year -= 1900; + + return true; +} + +static uc_value_t * +uc_mktime_common(uc_vm_t *vm, size_t nargs, bool local) +{ + struct tm tm; + time_t t; - if (tm.tm_year >= 1900) - tm.tm_year -= 1900; + if (!uc_timespec_to_tm(uc_fn_arg(0), &tm)) + return NULL; t = (local ? mktime : timegm)(&tm); @@ -5519,6 +5537,209 @@ uc_timegm(uc_vm_t *vm, size_t nargs) return uc_mktime_common(vm, nargs, false); } +/** + * Formats the given point in time according to the given format string, + * using the `strftime(3)` C library function. + * + * The time may be given either as epoch timestamp or as + * {@link module:core.TimeSpec|TimeSpec} dictionary. If omitted or `null`, the + * current time is used. + * + * By default, the time is interpreted and formatted according to the local + * system timezone. If the optional `utc` argument is truthy, it is interpreted + * and formatted as UTC time instead. + * + * When a TimeSpec dictionary is passed, the `wday` and `yday` fields are + * ignored and field values outside of their valid range are normalized, like + * with {@link module:core#timelocal|timelocal()} and + * {@link module:core#timegm|timegm()}. + * + * The set of supported conversion specifiers, as well as the output of + * locale-dependent ones such as `%a`, `%b` or `%c`, depends on the underlying + * C library. + * + * Returns the formatted string. + * + * Returns `null` if the format argument is not a string, if the given + * TimeSpec is invalid or if the time cannot be represented. + * + * @function module:core#strftime + * + * @param {string} format + * The `strftime(3)` format string. + * + * @param {number|module:core.TimeSpec} [time] + * The epoch timestamp or broken-down time dictionary to format. + * + * @param {boolean} [utc=false] + * Whether to interpret and format the time as UTC instead of local time. + * + * @returns {?string} + * + * @example + * strftime("%Y-%m-%d %H:%M:%S", 1647953502, true); + * // Returns "2022-03-22 12:51:42" + * + * strftime("%a, %d %b %Y %H:%M:%S %z", 1647953502); + * // Returns e.g. "Tue, 22 Mar 2022 13:51:42 +0100" for TZ=CET + * + * strftime("%d/%m/%Y", { mday: 40, mon: 10, year: 2022 }); + * // Returns "09/11/2022" + */ +static uc_value_t * +uc_strftime(uc_vm_t *vm, size_t nargs) +{ + uc_value_t *fmt = uc_fn_arg(0); + uc_value_t *ts = uc_fn_arg(1); + bool utc = ucv_is_truish(uc_fn_arg(2)); + char *sfmt, *buf = NULL; + uc_value_t *res = NULL; + size_t fmtlen, buflen, len; + struct tm tm; + time_t t; + + if (ucv_type(fmt) != UC_STRING) + return NULL; + + if (ucv_type(ts) == UC_OBJECT) { + if (!uc_timespec_to_tm(ts, &tm)) + return NULL; + + /* normalize fields and compute wday, yday, tm_gmtoff, tm_zone */ + if ((utc ? timegm : mktime)(&tm) == (time_t)-1) + return NULL; + } + else { + t = ts ? (time_t)ucv_to_integer(ts) : time(NULL); + + if (!(utc ? gmtime_r : localtime_r)(&t, &tm)) + return NULL; + } + + /* + * strftime() returns 0 both for an empty result and for an insufficient + * buffer, so append a sentinel character to the format to be able to + * distinguish both cases. The format is truncated at the first NUL byte + * since strftime() would not process anything beyond it anyway. + */ + fmtlen = strlen(ucv_string_get(fmt)); + sfmt = xalloc(fmtlen + 2); + memcpy(sfmt, ucv_string_get(fmt), fmtlen); + sfmt[fmtlen] = ' '; + + for (buflen = 64 + fmtlen * 4; buflen <= 1024 * 1024; buflen *= 2) { + buf = xrealloc(buf, buflen); + len = strftime(buf, buflen, sfmt, &tm); + + if (len > 0) { + res = ucv_string_new_length(buf, len - 1); + break; + } + } + + free(sfmt); + free(buf); + + return res; +} + +/** + * Parses the given string according to the given format string, using the + * `strptime(3)` C library function, and returns the result as broken-down + * {@link module:core.TimeSpec|TimeSpec} dictionary. + * + * This is the inverse operation of {@link module:core#strftime|strftime()}. + * The returned dictionary may be passed to + * {@link module:core#timelocal|timelocal()} or + * {@link module:core#timegm|timegm()} to obtain an epoch value. + * + * Fields not covered by the format string default to January 1st, 1900, + * 00:00:00. The `isdst` field is set to `-1` unless the parsed input + * determines it, which lets `timelocal()` figure out whether daylight saving + * time is in effect. The `wday` and `yday` fields are always computed from the + * resulting date. + * + * The entire input string must be matched by the format, only trailing + * whitespace is permitted after the last conversion. + * + * The set of supported conversion specifiers, as well as the accepted input + * for locale-dependent ones such as `%a`, `%b` or `%c`, depends on the + * underlying C library. + * + * Returns the broken-down time dictionary. + * + * Returns `null` if any argument is not a string or if the input does not + * match the format. + * + * @function module:core#strptime + * + * @param {string} input + * The string to parse. + * + * @param {string} format + * The `strptime(3)` format string. + * + * @returns {?module:core.TimeSpec} + * + * @example + * strptime("2022-03-22 13:51:42", "%Y-%m-%d %H:%M:%S"); + * // Returns: + * // { + * // sec: 42, + * // min: 51, + * // hour: 13, + * // mday: 22, + * // mon: 3, + * // year: 2022, + * // wday: 2, + * // yday: 81, + * // isdst: -1 + * // } + * + * timegm(strptime("22/Mar/2022:12:51:42", "%d/%b/%Y:%H:%M:%S")); + * // Returns 1647953502 + * + * strptime("2022-03-22", "%Y-%m-%d %H:%M"); + * // Returns null + */ +static uc_value_t * +uc_strptime(uc_vm_t *vm, size_t nargs) +{ + uc_value_t *input = uc_fn_arg(0); + uc_value_t *fmt = uc_fn_arg(1); + struct tm tm = { .tm_mday = 1, .tm_isdst = -1 }, norm; + const char *end; + + if (ucv_type(input) != UC_STRING || ucv_type(fmt) != UC_STRING) + return NULL; + + end = strptime(ucv_string_get(input), ucv_string_get(fmt), &tm); + + if (!end) + return NULL; + + while (isspace((unsigned char)*end)) + end++; + + if (*end) + return NULL; + + /* + * Not all strptime() implementations compute wday and yday from parsed + * dates, so derive them from a normalized copy of the parsed date. + */ + norm = tm; + norm.tm_isdst = 0; + errno = 0; + + if (timegm(&norm) != (time_t)-1 || errno == 0) { + tm.tm_wday = norm.tm_wday; + tm.tm_yday = norm.tm_yday; + } + + return uc_tm_to_timespec(vm, &tm); +} + /** * Reads the current second and microsecond value of the system clock. * @@ -6369,6 +6590,8 @@ const uc_function_list_t uc_stdlib_functions[] = { [UC_FN_LOADFILE ] = { "loadfile", uc_loadfile }, [UC_FN_CALL ] = { "call", uc_callfunc }, [UC_FN_SIGNAL ] = { "signal", uc_signal }, + [UC_FN_STRFTIME ] = { "strftime", uc_strftime }, + [UC_FN_STRPTIME ] = { "strptime", uc_strptime }, }; diff --git a/tests/custom/03_stdlib/70_strftime b/tests/custom/03_stdlib/70_strftime new file mode 100644 index 00000000..997b83c2 --- /dev/null +++ b/tests/custom/03_stdlib/70_strftime @@ -0,0 +1,72 @@ +The `strftime()` function formats a point in time, given either as epoch +timestamp or as broken-down date and time dictionary, according to the +given `strftime(3)` format string. By default the local timezone is used, +a truthy third argument selects UTC. + +Returns `null` if the format is not a string or if the given time spec is +invalid. + +-- Testcase -- +{% + let t = time(); + let s = strftime("%s"); + let f = ""; + + for (let i = 0; i < 50; i++) + f += "%Y-%m-%d "; + + // assert that strftime without time argument uses the current time + assert(+s >= t && +s <= t + 5, "strftime() result does not match time()"); + + printf("%.J\n", [ + // epoch value, local time and UTC + strftime("%Y-%m-%d %H:%M:%S %Z", 1647953502), + strftime("%Y-%m-%d %H:%M:%S %z", 1647953502, true), + + // daylight saving time + strftime("%Y-%m-%d %H:%M:%S %z", 1657453502), + + // time spec dictionary, wday/yday are computed + strftime("%F %T %u %j", { sec: 42, min: 51, hour: 13, mday: 22, mon: 3, year: 2022 }), + + // out of range values are normalized + strftime("%F", { mday: 40, mon: 10, year: 2022 }, true), + + // round trip + strftime("%c", localtime(1647953502)) == strftime("%c", 1647953502), + + // literal text, escaped percent and empty results + strftime("Year %Y, 100%%", 0, true), + length(strftime("", 0)), + + // long output exceeding the initial buffer + length(strftime(f, 0, true)), + + // invalid arguments + strftime(123), + strftime("%Y", { mon: 1 }), + strftime("%Y", { mday: 1, year: 2022 }) + ]); +%} +-- End -- + +-- Vars -- +TZ=CET-1CEST,M3.5.0/2,M10.5.0/3 +-- End -- + +-- Expect stdout -- +[ + "2022-03-22 13:51:42 CET", + "2022-03-22 12:51:42 +0000", + "2022-07-10 13:45:02 +0200", + "2022-03-22 13:51:42 2 081", + "2022-11-09", + true, + "Year 1970, 100%", + 0, + 550, + null, + null, + null +] +-- End -- diff --git a/tests/custom/03_stdlib/71_strptime b/tests/custom/03_stdlib/71_strptime new file mode 100644 index 00000000..ef6a3728 --- /dev/null +++ b/tests/custom/03_stdlib/71_strptime @@ -0,0 +1,82 @@ +The `strptime()` function parses a string according to the given +`strptime(3)` format string and returns the result as broken-down date +and time dictionary, suitable for `timelocal()` and `timegm()`. + +Fields not covered by the format default to 1900-01-01 00:00:00, `isdst` +defaults to `-1` and `wday` and `yday` are computed from the parsed date. + +Returns `null` if an argument is not a string or if the input does not +entirely match the format. + +-- Testcase -- +{% + // full date and time + printf("%.J\n", strptime("2022-03-22 13:51:42", "%Y-%m-%d %H:%M:%S")); + + // time only, date defaults to 1900-01-01 + printf("%.J\n", strptime("13:51", "%H:%M")); + + printf("%.J\n", [ + // round trips with timegm(), timelocal() and strftime() + timegm(strptime("22/Mar/2022:12:51:42", "%d/%b/%Y:%H:%M:%S")), + timelocal(strptime("2022-03-22 13:51:42", "%F %T")), + timelocal(strptime("2022-07-10 13:45:02", "%F %T")), + strftime("%F %T", strptime("2022-03-22T13:51:42", "%Y-%m-%dT%H:%M:%S")), + + // leading and trailing whitespace is accepted + strptime(" 2022-03-22 ", " %Y-%m-%d").yday, + + // mismatching or incomplete input + strptime("2022-03-22", "%Y-%m-%d %H:%M"), + strptime("2022-03-22 junk", "%Y-%m-%d"), + strptime("2022-13-01", "%Y-%m-%d"), + strptime("", "%Y"), + + // invalid arguments + strptime(2022, "%Y"), + strptime("2022", null) + ]); +%} +-- End -- + +-- Vars -- +TZ=CET-1CEST,M3.5.0/2,M10.5.0/3 +-- End -- + +-- Expect stdout -- +{ + "sec": 42, + "min": 51, + "hour": 13, + "mday": 22, + "mon": 3, + "year": 2022, + "wday": 2, + "yday": 81, + "isdst": -1 +} +{ + "sec": 0, + "min": 51, + "hour": 13, + "mday": 1, + "mon": 1, + "year": 1900, + "wday": 1, + "yday": 1, + "isdst": -1 +} +[ + 1647953502, + 1647953502, + 1657453502, + "2022-03-22 13:51:42", + 81, + null, + null, + null, + null, + null, + null +] +-- End -- From e77445cfb935684224c631a1548c1d90a954036c Mon Sep 17 00:00:00 2001 From: Alexei Znamensky Date: Sun, 11 Oct 2026 02:03:07 +0000 Subject: [PATCH 2/3] lib: strftime(): compute %s from the actual epoch value strftime(3) implementations derive the %s epoch value by passing the broken-down time to mktime(), which interprets it as local time. When formatting UTC times in a non-UTC timezone, this yields an epoch value that is off by the UTC offset: TZ=CET strftime("%s", 1647953502, true); -> "1647949902" Substitute %s with the known epoch value before invoking strftime(3), skipping escaped %% sequences, so that the result is correct regardless of the timezone and consistent across C library implementations. Extend the strftime test with %s coverage for local and UTC epoch values and TimeSpec input, negative epoch values and %% escaping. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Alexei Znamensky --- lib.c | 48 +++++++++++++++++++++--------- tests/custom/03_stdlib/70_strftime | 14 +++++++++ 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/lib.c b/lib.c index 88107799..e4bb4c97 100644 --- a/lib.c +++ b/lib.c @@ -5592,9 +5592,10 @@ uc_strftime(uc_vm_t *vm, size_t nargs) uc_value_t *fmt = uc_fn_arg(0); uc_value_t *ts = uc_fn_arg(1); bool utc = ucv_is_truish(uc_fn_arg(2)); - char *sfmt, *buf = NULL; uc_value_t *res = NULL; - size_t fmtlen, buflen, len; + uc_stringbuf_t *sfmt; + size_t buflen, len; + char *p, *buf = NULL; struct tm tm; time_t t; @@ -5606,7 +5607,9 @@ uc_strftime(uc_vm_t *vm, size_t nargs) return NULL; /* normalize fields and compute wday, yday, tm_gmtoff, tm_zone */ - if ((utc ? timegm : mktime)(&tm) == (time_t)-1) + t = (utc ? timegm : mktime)(&tm); + + if (t == (time_t)-1) return NULL; } else { @@ -5617,19 +5620,36 @@ uc_strftime(uc_vm_t *vm, size_t nargs) } /* - * strftime() returns 0 both for an empty result and for an insufficient - * buffer, so append a sentinel character to the format to be able to - * distinguish both cases. The format is truncated at the first NUL byte - * since strftime() would not process anything beyond it anyway. + * Substitute %s with the epoch value ourselves since strftime() + * implementations derive it by passing the broken-down time to mktime(), + * which yields a wrong result for UTC times in non-UTC timezones. + * + * Furthermore, strftime() returns 0 both for an empty result and for an + * insufficient buffer, so append a sentinel character to the format to + * be able to distinguish both cases. The format is truncated at the first + * NUL byte since strftime() would not process anything beyond it anyway. */ - fmtlen = strlen(ucv_string_get(fmt)); - sfmt = xalloc(fmtlen + 2); - memcpy(sfmt, ucv_string_get(fmt), fmtlen); - sfmt[fmtlen] = ' '; + sfmt = xprintbuf_new(); + + for (p = ucv_string_get(fmt); *p; p++) { + if (p[0] == '%' && p[1] == 's') { + ucv_stringbuf_printf(sfmt, "%" PRId64, (int64_t)t); + p++; + } + else if (p[0] == '%' && p[1]) { + ucv_stringbuf_addstr(sfmt, p, 2); + p++; + } + else { + ucv_stringbuf_addstr(sfmt, p, 1); + } + } + + ucv_stringbuf_append(sfmt, " "); - for (buflen = 64 + fmtlen * 4; buflen <= 1024 * 1024; buflen *= 2) { + for (buflen = 64 + sfmt->bpos * 4; buflen <= 1024 * 1024; buflen *= 2) { buf = xrealloc(buf, buflen); - len = strftime(buf, buflen, sfmt, &tm); + len = strftime(buf, buflen, sfmt->buf, &tm); if (len > 0) { res = ucv_string_new_length(buf, len - 1); @@ -5637,7 +5657,7 @@ uc_strftime(uc_vm_t *vm, size_t nargs) } } - free(sfmt); + printbuf_free(sfmt); free(buf); return res; diff --git a/tests/custom/03_stdlib/70_strftime b/tests/custom/03_stdlib/70_strftime index 997b83c2..84fd1fce 100644 --- a/tests/custom/03_stdlib/70_strftime +++ b/tests/custom/03_stdlib/70_strftime @@ -23,6 +23,14 @@ invalid. strftime("%Y-%m-%d %H:%M:%S %Z", 1647953502), strftime("%Y-%m-%d %H:%M:%S %z", 1647953502, true), + // epoch value is independent of the timezone + strftime("%s %%s", 1647953502), + strftime("%s %%s", 1647953502, true), + strftime("%s", gmtime(1647953502), true), + strftime("%s", localtime(1647953502)), + strftime("%s", -86400, true), + strftime("%%%s%%%%s", 0, true), + // daylight saving time strftime("%Y-%m-%d %H:%M:%S %z", 1657453502), @@ -58,6 +66,12 @@ TZ=CET-1CEST,M3.5.0/2,M10.5.0/3 [ "2022-03-22 13:51:42 CET", "2022-03-22 12:51:42 +0000", + "1647953502 %s", + "1647953502 %s", + "1647953502", + "1647953502", + "-86400", + "%0%%s", "2022-07-10 13:45:02 +0200", "2022-03-22 13:51:42 2 081", "2022-11-09", From e6d6f8b799e451d966df3db8d7bd7313196eecfc Mon Sep 17 00:00:00 2001 From: Alexei Znamensky Date: Sun, 11 Oct 2026 03:45:32 +0000 Subject: [PATCH 3/3] lib: strftime(): fix build with gcc -Werror=format-nonliteral GCC knows strftime() as format function and rejects the runtime supplied format string when building with -Werror=format-nonliteral: lib.c: error: format not a string literal, format string not checked Since passing a caller supplied format is the whole point of the function, suppress the diagnostic for this call. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Alexei Znamensky --- lib.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib.c b/lib.c index e4bb4c97..e34a88d2 100644 --- a/lib.c +++ b/lib.c @@ -5649,7 +5649,10 @@ uc_strftime(uc_vm_t *vm, size_t nargs) for (buflen = 64 + sfmt->bpos * 4; buflen <= 1024 * 1024; buflen *= 2) { buf = xrealloc(buf, buflen); +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wformat-nonliteral" len = strftime(buf, buflen, sfmt->buf, &tm); +#pragma GCC diagnostic pop if (len > 0) { res = ucv_string_new_length(buf, len - 1);