From b9dec03db560a9b3999376171860fefb95a5b1f7 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Fri, 14 Aug 2026 12:02:35 +0200 Subject: [PATCH 1/2] Rewrite strftime as a single-pass byte scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit strftime split the format string on `%` and glued a `%` back onto the front of every segment, including the first — which is the literal text that came before the first directive and was never a specifier. That ate the first character of any leading literal and re-expanded it: "Date: %Y" rendered as "03/15/24ate: 2024", because `D` became `%D`. A format string could not begin with text at all. The same split also made a format string with no `%` in it abort the process: `split-by` returns one segment, so `ln` is 0, and core's `Array.range 0 0 1` picks `>=` as its loop comparison and writes past its one-element allocation. That is an upstream core bug; the rewrite does not call `Array.range`, which removes the only trigger here. Datetime.format mixed byte and character indices: `String.index-of` returns a byte offset, but `String.prefix`/`suffix` count characters. Any non-ASCII literal before the directive mis-sliced, and a format whose byte count exceeded its character count ran `Array.prefix` past the end and aborted. It now slices with `String.byte-slice` throughout, and passes a slice with no directive through unchanged rather than reading a specifier out of it. Three `%`-escape cases change, all of them toward glibc: "%%Y" is now "%Y" rather than "%2024", "%%%Y" is "%2024" rather than "%%2024", and a trailing lone "%" renders as "%" rather than vanishing. Every one of the 33 specifiers is byte-for-byte unchanged across three Datetimes. --- docs/Datetime.html | 5 +++- test/time.carp | 62 ++++++++++++++++++++++++++++++++++++++++++++++ time.carp | 53 ++++++++++++++++++++++++++------------- 3 files changed, 102 insertions(+), 18 deletions(-) diff --git a/docs/Datetime.html b/docs/Datetime.html index 043cf1f..9c9de78 100644 --- a/docs/Datetime.html +++ b/docs/Datetime.html @@ -1108,7 +1108,7 @@

defn

- (Fn [a, (Ref String b)] String) + (Fn [(Ref Datetime a), (Ref String b)] String)

                         (strftime dt s)
@@ -1116,6 +1116,9 @@ 

formats the Datetime dt according to the string provided in s. The formatting options mimic the C interface.

+

Text outside a directive is copied verbatim. %% renders a single %, as does +a trailing % with nothing after it; an unrecognized specifier renders as the +character that follows the %.

You can alternatively use the fmt macro or format function, but you’ll only be able to use one of the formatting properties there. Thus this function is provided as a convenience for fine-grained string tweaking.

diff --git a/test/time.carp b/test/time.carp index 7446600..089e3dd 100644 --- a/test/time.carp +++ b/test/time.carp @@ -998,6 +998,68 @@ &@"Mar" &(Datetime.strftime &(Datetime.date 2024 3 15) "%h") "strftime %h aliases the abbreviated month name") + + (assert-equal test + &@"Date: 2024" + &(Datetime.strftime &(Datetime.date 2024 3 15) "Date: %Y") + "strftime keeps a leading literal") + (assert-equal test + &@"Day 15, ok" + &(Datetime.strftime &(Datetime.date 2024 3 15) "Day %d, ok") + "strftime keeps a leading literal starting with a specifier letter") + (assert-equal test + &@"zzz 2024" + &(Datetime.strftime &(Datetime.date 2024 3 15) "zzz %Y") + "strftime keeps a leading literal starting with a tz specifier letter") + (assert-equal test + &@"hello" + &(Datetime.strftime &(Datetime.date 2024 3 15) "hello") + "strftime passes through a format with no directive") + (assert-equal test + &@"" + &(Datetime.strftime &(Datetime.date 2024 3 15) "") + "strftime renders the empty format as the empty string") + (assert-equal test + &@"100% done 2024" + &(Datetime.strftime &(Datetime.date 2024 3 15) "100%% done %Y") + "strftime renders %% as a literal percent") + (assert-equal test + &@"%Y" + &(Datetime.strftime &(Datetime.date 2024 3 15) "%%Y") + "strftime does not expand the letter after an escaped percent") + (assert-equal test + &@"%2024" + &(Datetime.strftime &(Datetime.date 2024 3 15) "%%%Y") + "strftime resumes directives after an escaped percent") + (assert-equal test + &@"Q" + &(Datetime.strftime &(Datetime.date 2024 3 15) "%Q") + "strftime renders an unknown specifier as the character itself") + (assert-equal test + &@"2024%" + &(Datetime.strftime &(Datetime.date 2024 3 15) "%Y%") + "strftime renders a trailing lone percent as a literal percent") + (assert-equal test + &@"%" + &(Datetime.strftime &(Datetime.date 2024 3 15) "%") + "strftime renders a lone percent as a literal percent") + (assert-equal test + &@"Größe: 2024 μs" + &(Datetime.strftime &(Datetime.date 2024 3 15) "Größe: %Y μs") + "strftime copies non-ASCII literals verbatim") + (assert-equal test + &@"Größe: 2024" + &(Datetime.format "Größe: %Y" &(Datetime.date 2024 3 15)) + "format slices non-ASCII literals by byte") + (assert-equal test + &@"Größe: 2024" + &(fmt "Größe: %Y" &(Datetime.date 2024 3 15)) + "fmt reaches format with a non-ASCII literal") + (assert-equal test + &@"hello" + &(Datetime.format "hello" &(Datetime.date 2024 3 15)) + "format passes through a slice with no directive") + (assert-equal test &(Result.Success (Datetime.date 2024 3 15)) &(Datetime.strptime "2024-03-15 11" "%Y-%m-%d %V") diff --git a/time.carp b/time.carp index b79df5a..dd72660 100644 --- a/time.carp +++ b/time.carp @@ -480,9 +480,15 @@ All of the time information is obtained from the operating system directly.") (register format (Fn [(Ref String) (Ref Datetime)] String)) (implements format Datetime.format) + (register format-for (Fn [Char (Ref Datetime)] String)) + (doc strftime "formats the `Datetime` `dt` according to the string provided in `s`. The formatting options mimic [the C interface](http://www.cplusplus.com/reference/ctime/strftime/). +Text outside a directive is copied verbatim. `%%` renders a single `%`, as does +a trailing `%` with nothing after it; an unrecognized specifier renders as the +character that follows the `%`. + You can alternatively use the `fmt` macro or `format` function, but you’ll only be able to use one of the formatting properties there. Thus this function is provided as a convenience for fine-grained string tweaking. @@ -493,18 +499,29 @@ Example: &(Datetime.strftime &(Datetime.now) \"%Y-%m-%d %I:%M:%S.%n %p %z\") ```") (defn strftime [dt s] - (let [strings (String.split-by s &[\%]) - ln (Int.dec (Array.length &strings)) - rng (Result.from-success (Array.range 0 ln 1) [])] - (String.concat - &(Array.zip - &(fn [x i] - (cond - (and (String.empty? x) (or (= i &0) (= i &ln))) @"" - (String.empty? x) @"%" - (format &(String.concat &[@"%" @x]) dt))) - &strings - &rng)))) + (let-do [result @"" + len (String.length s) + i 0 + lit 0] + (while (< i len) + (if (= \% (String.char-at s i)) + (do + (when (< lit i) + (set! result (String.concat &[result (String.byte-slice s lit i)]))) + (if (< (Int.inc i) len) + (let-do [spec (String.char-at s (Int.inc i))] + (set! result + (String.concat + &[result (if (= \% spec) @"%" (format-for spec dt))])) + (set! i (+ i 2))) + (do + (set! result (String.concat &[result @"%"])) + (set! i (Int.inc i)))) + (set! lit i)) + (set! i (Int.inc i)))) + (when (< lit len) + (set! result (String.concat &[result (String.byte-slice s lit len)]))) + result)) (private format-for) (hidden format-for) @@ -594,11 +611,13 @@ Example: It mimics [the C interface](http://www.cplusplus.com/reference/ctime/strftime/).") (defn format [s dt] (let [idx (String.index-of s \%) - formatter (String.char-at s (Int.inc idx))] - (String.concat - &[(String.prefix s idx) - (format-for formatter dt) - (String.suffix s (+ idx 2))]))) + len (String.length s)] + (if (or (< idx 0) (= idx (Int.dec len))) + @s + (String.concat + &[(String.byte-slice s 0 idx) + (format-for (String.char-at s (Int.inc idx)) dt) + (String.byte-slice s (+ idx 2) len)])))) (private parse-digits) (hidden parse-digits) From 9291a1815e1a67f0b55755ce1a937718155d7179 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Fri, 14 Aug 2026 17:44:08 +0200 Subject: [PATCH 2/2] Emit an unknown specifier's byte instead of re-encoding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit format-for's fallback was (str c), and Char.str is utf8encode. The scanner hands it a raw byte from String.char-at, so a multi-byte character's lead byte came back out as the two-byte UTF-8 encoding of that byte value as a codepoint: "%ä" rendered as [195 131 164] rather than [195 164]. String.from-bytes emits the single byte, and the scanner's existing literal handling copies the continuation bytes that follow, so the character reassembles on its own. This makes strftime's docstring — "an unrecognized specifier renders as the character that follows the %" — literally true rather than ASCII-only, which is what the reviewer flagged. Sweeping every specifier byte 1..255 through both strftime and format, 1..127 are byte-for-byte unchanged and 128..255 each now emit themselves instead of a two-byte double-encoding. --- test/time.carp | 8 ++++++++ time.carp | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/test/time.carp b/test/time.carp index 089e3dd..498afeb 100644 --- a/test/time.carp +++ b/test/time.carp @@ -1035,6 +1035,14 @@ &@"Q" &(Datetime.strftime &(Datetime.date 2024 3 15) "%Q") "strftime renders an unknown specifier as the character itself") + (assert-equal test + &[195b 164b] + &(String.to-bytes &(Datetime.strftime &(Datetime.date 2024 3 15) "%ä")) + "strftime renders an unknown non-ASCII specifier as that character") + (assert-equal test + &[226b 130b 172b 120b] + &(String.to-bytes &(Datetime.strftime &(Datetime.date 2024 3 15) "%€x")) + "strftime reassembles a multi-byte unknown specifier before a literal") (assert-equal test &@"2024%" &(Datetime.strftime &(Datetime.date 2024 3 15) "%Y%") diff --git a/time.carp b/time.carp index dd72660..de06493 100644 --- a/time.carp +++ b/time.carp @@ -604,7 +604,7 @@ Example: (strftime dt "%I:%M:%S %p") \h (month-short-string dt) - (str c))) + (String.from-bytes &[(Byte.from-int (Char.to-int c))]))) (doc format "The interface implementation of `format` for `Datetime` values.