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 <noreply@anthropic.com>
This commit is contained in:
Yukihiro "Matz" Matsumoto
2025-10-12 15:03:42 +09:00
parent daaaafeff8
commit b31e22f0bc
3 changed files with 290 additions and 0 deletions
+7
View File
@@ -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
+132
View File
@@ -0,0 +1,132 @@
/*
** strftime.c - Time#strftime
**
** See Copyright Notice in mruby.h
*/
#include <mruby.h>
#include <mruby/string.h>
#include <mruby/time.h>
#include <mruby/class.h>
#include <mruby/presym.h>
#include <time.h>
#include <string.h>
#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)
{
}
+151
View File
@@ -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