From b31e22f0bc333637eb773ed47a2c4f0954dccb68 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Sun, 12 Oct 2025 15:03:42 +0900 Subject: [PATCH] mruby-strftime: implement time#strftime method add new mruby-strftime gem providing time#strftime for formatting time objects using standard format specifiers. implementation features: - uses mrb_time_get_tm() api for accessing time components - handles nul bytes in format strings correctly - dynamic buffer allocation for variable-length output - comprehensive test coverage including edge cases Co-authored-by: Claude --- mrbgems/mruby-strftime/mrbgem.rake | 7 ++ mrbgems/mruby-strftime/src/strftime.c | 132 +++++++++++++++++++++ mrbgems/mruby-strftime/test/strftime.rb | 151 ++++++++++++++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 mrbgems/mruby-strftime/mrbgem.rake create mode 100644 mrbgems/mruby-strftime/src/strftime.c create mode 100644 mrbgems/mruby-strftime/test/strftime.rb diff --git a/mrbgems/mruby-strftime/mrbgem.rake b/mrbgems/mruby-strftime/mrbgem.rake new file mode 100644 index 000000000..89d37304b --- /dev/null +++ b/mrbgems/mruby-strftime/mrbgem.rake @@ -0,0 +1,7 @@ +MRuby::Gem::Specification.new('mruby-strftime') do |spec| + spec.license = 'MIT' + spec.author = 'mruby developers' + spec.summary = 'Time#strftime implementation' + + spec.add_dependency 'mruby-time' +end diff --git a/mrbgems/mruby-strftime/src/strftime.c b/mrbgems/mruby-strftime/src/strftime.c new file mode 100644 index 000000000..cef7ca9d7 --- /dev/null +++ b/mrbgems/mruby-strftime/src/strftime.c @@ -0,0 +1,132 @@ +/* +** strftime.c - Time#strftime +** +** See Copyright Notice in mruby.h +*/ + +#include +#include +#include +#include +#include +#include +#include + +#define INITIAL_BUFFER_SIZE 64 +#define MAX_BUFFER_SIZE 4096 + +/* + * call-seq: + * time.strftime(format) -> string + * + * Formats time according to the directives in the given format string. + * + * The format string may contain NUL bytes, which will be preserved in + * the output string. + * + * Common format directives: + * %Y - Year with century (e.g., 2023) + * %m - Month of the year (01-12) + * %d - Day of the month (01-31) + * %H - Hour of the day, 24-hour clock (00-23) + * %M - Minute of the hour (00-59) + * %S - Second of the minute (00-60) + * %% - Literal % character + * + * See your system's strftime(3) documentation for a complete list. + * + * t = Time.new(2023, 12, 25, 10, 30, 45) + * t.strftime("%Y-%m-%d %H:%M:%S") #=> "2023-12-25 10:30:45" + * t.strftime("%A, %B %d, %Y") #=> "Monday, December 25, 2023" + */ +static mrb_value +mrb_time_strftime(mrb_state *mrb, mrb_value self) +{ + const char *format; + mrb_int format_len; + struct tm *tm; + mrb_value result; + const char *fmt_ptr; + mrb_int remaining; + + mrb_get_args(mrb, "s", &format, &format_len); + tm = mrb_time_get_tm(mrb, self); + + result = mrb_str_new(mrb, NULL, 0); + fmt_ptr = format; + remaining = format_len; + + /* Process format string in segments, handling NUL bytes */ + while (remaining > 0) { + const char *nul_pos = (const char *)memchr(fmt_ptr, '\0', (size_t)remaining); + mrb_int segment_len = nul_pos ? (nul_pos - fmt_ptr) : remaining; + + /* Process this segment (up to NUL or end of string) */ + if (segment_len > 0) { + char *segment; + size_t buf_size; + char *buf; + size_t n; + + /* Create null-terminated copy of this segment */ + segment = (char *)mrb_malloc(mrb, (size_t)segment_len + 1); + memcpy(segment, fmt_ptr, (size_t)segment_len); + segment[segment_len] = '\0'; + + /* Allocate buffer for formatted output */ + buf_size = INITIAL_BUFFER_SIZE; + buf = (char *)mrb_malloc(mrb, buf_size); + + /* Try formatting; grow buffer if needed */ + while (1) { + n = strftime(buf, buf_size, segment, tm); + + /* + * strftime returns 0 if: + * 1. Buffer is too small (retry with larger buffer) + * 2. Format produces empty result (stop retrying) + * We distinguish by checking buffer size limit. + */ + if (n > 0 || buf_size >= MAX_BUFFER_SIZE) { + break; + } + + /* Double buffer size and retry */ + buf_size *= 2; + buf = (char *)mrb_realloc(mrb, buf, buf_size); + } + + /* Append formatted output to result */ + mrb_str_cat(mrb, result, buf, n); + + mrb_free(mrb, buf); + mrb_free(mrb, segment); + } + + /* If there was a NUL, append it to result and advance past it */ + if (nul_pos) { + mrb_str_cat(mrb, result, "\0", 1); + fmt_ptr = nul_pos + 1; + remaining -= segment_len + 1; + } + else { + break; + } + } + + return result; +} + +void +mrb_mruby_strftime_gem_init(mrb_state *mrb) +{ + struct RClass *time_class; + + time_class = mrb_class_get_id(mrb, MRB_SYM(Time)); + mrb_define_method_id(mrb, time_class, MRB_SYM(strftime), mrb_time_strftime, MRB_ARGS_REQ(1)); +} + +void +mrb_mruby_strftime_gem_final(mrb_state *mrb) +{ +} diff --git a/mrbgems/mruby-strftime/test/strftime.rb b/mrbgems/mruby-strftime/test/strftime.rb new file mode 100644 index 000000000..601d528c9 --- /dev/null +++ b/mrbgems/mruby-strftime/test/strftime.rb @@ -0,0 +1,151 @@ +assert('Time#strftime') do + t = Time.now + assert_true t.respond_to?(:strftime) +end + +assert('Time#strftime with basic formats') do + t = Time.gm(2023, 12, 25, 10, 30, 45) + + assert_equal '2023', t.strftime('%Y') + assert_equal '23', t.strftime('%y') + assert_equal '12', t.strftime('%m') + assert_equal '25', t.strftime('%d') + assert_equal '10', t.strftime('%H') + assert_equal '30', t.strftime('%M') + assert_equal '45', t.strftime('%S') +end + +assert('Time#strftime with combined formats') do + t = Time.gm(2023, 12, 25, 10, 30, 45) + + assert_equal '2023-12-25', t.strftime('%Y-%m-%d') + assert_equal '10:30:45', t.strftime('%H:%M:%S') + assert_equal '2023-12-25 10:30:45', t.strftime('%Y-%m-%d %H:%M:%S') +end + +assert('Time#strftime with weekday formats') do + # 2023-12-25 is Monday + t = Time.gm(2023, 12, 25) + + result = t.strftime('%A') + assert_true result.include?('Mon') || result == 'Monday' + + result = t.strftime('%a') + assert_true result.length >= 2 + + assert_equal '1', t.strftime('%w') # Monday is day 1 +end + +assert('Time#strftime with month formats') do + t = Time.gm(2023, 12, 25) + + result = t.strftime('%B') + assert_true result.include?('Dec') || result == 'December' + + result = t.strftime('%b') + assert_true result.length >= 2 +end + +assert('Time#strftime with literal percent') do + t = Time.gm(2023, 12, 25) + + assert_equal '%', t.strftime('%%') + assert_equal '100%', t.strftime('100%%') + assert_equal '2023%12', t.strftime('%Y%%%m') +end + +assert('Time#strftime with empty format') do + t = Time.now + + assert_equal '', t.strftime('') +end + +assert('Time#strftime with no format specifiers') do + t = Time.now + + assert_equal 'hello', t.strftime('hello') + assert_equal 'test123', t.strftime('test123') +end + +assert('Time#strftime with NUL byte') do + t = Time.gm(2023, 12, 25) + + result = t.strftime("foo\0bar") + assert_equal 7, result.length + assert_equal 'f', result[0] + assert_equal 'o', result[1] + assert_equal 'o', result[2] + assert_equal "\0", result[3] + assert_equal 'b', result[4] + assert_equal 'a', result[5] + assert_equal 'r', result[6] +end + +assert('Time#strftime with NUL and format specifiers') do + t = Time.gm(2023, 12, 25) + + result = t.strftime("year\0%Y") + assert_true result.length >= 9 # "year\0" (5) + "2023" (4) + assert_true result.include?("\0") + # Check last 4 characters are "2023" + assert_equal '2023', result[-4, 4] +end + +assert('Time#strftime with multiple NULs') do + t = Time.now + + result = t.strftime("\0\0") + assert_equal 2, result.length + assert_equal "\0\0", result +end + +assert('Time#strftime with NUL at beginning') do + t = Time.gm(2023, 1, 1) + + result = t.strftime("\0%Y") + assert_equal 5, result.length + assert_equal "\0", result[0] + assert_equal '2023', result[1, 4] +end + +assert('Time#strftime with NUL at end') do + t = Time.gm(2023, 1, 1) + + result = t.strftime("%Y\0") + assert_equal 5, result.length + assert_equal '2023', result[0, 4] + assert_equal "\0", result[4] +end + +assert('Time#strftime preserves timezone') do + t_utc = Time.utc(2023, 1, 1, 12, 0, 0) + t_local = Time.local(2023, 1, 1, 12, 0, 0) + + # Both should format their time correctly + assert_equal '12', t_utc.strftime('%H') + assert_equal '12', t_local.strftime('%H') +end + +assert('Time#strftime with various time components') do + t = Time.gm(2023, 6, 15, 14, 23, 7) + + assert_equal '6', t.strftime('%-m') if t.strftime('%-m') != '' # Skip if platform doesn't support %- + assert_equal '15', t.strftime('%d') + assert_equal '14', t.strftime('%H') + assert_equal '23', t.strftime('%M') + assert_equal '07', t.strftime('%S') +end + +assert('Time#strftime argument type error') do + t = Time.now + + assert_raise(TypeError) { t.strftime(123) } + assert_raise(TypeError) { t.strftime(nil) } +end + +assert('Time#strftime argument count error') do + t = Time.now + + assert_raise(ArgumentError) { t.strftime } + assert_raise(ArgumentError) { t.strftime('%Y', '%m') } +end