From: Vsevolod Stakhov Date: Mon, 6 Oct 2025 13:22:26 +0000 (+0100) Subject: [Feature] Add type specifiers support to lua_logger X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=refs%2Fpull%2F5668%2Fhead;p=thirdparty%2Frspamd.git [Feature] Add type specifiers support to lua_logger Add support for format type specifiers in lua_logger: - %d - signed integer (int64) - %ud - unsigned integer (uint64) - %f - floating point with smart formatting (no trailing zeros) - %.Nf - floating point with N decimal places precision - %% - escape literal percent sign Type specifiers can be combined with positional (%1d) and sequential (%d) argument references. String to number conversion is supported. Added comprehensive unit tests. --- diff --git a/src/lua/lua_logger.c b/src/lua/lua_logger.c index 1d3858440d..bdfc218ebc 100644 --- a/src/lua/lua_logger.c +++ b/src/lua/lua_logger.c @@ -27,7 +27,7 @@ local rspamd_logger = require "rspamd_logger" local a = 'string' local b = 1.5 -local c = 1 +local c = 100 local d = { 'aa', 1, @@ -39,17 +39,30 @@ local e = { } -- New extended interface --- % means numeric arguments and %s means the next argument --- for example %1, %2, %s: %s would mean the third argument - +-- Positional arguments: % (e.g., %1, %2, %3) +-- Sequential arguments: %s (uses the next argument) +-- Type specifiers can be combined with positional or sequential: +-- %d - signed integer +-- %ud - unsigned integer +-- %f - double (floating point) +-- %.Nf - double with N decimal places (e.g., %.2f for 2 decimals) + +-- Default formatting (automatic type detection) rspamd_logger.info('a=%1, b=%2, c=%3, d=%4, e=%s', a, b, c, d, e) --- Output: a=string, b=1.50000, c=1, d={[1] = aa, [2] = 1, [3] = bb} e={[key]=value, [key2]=1.0} +-- Output: a=string, b=1.500000, c=100, d={[1] = aa, [2] = 1, [3] = bb} e={[key]=value, [key2]=1.0} --- Create string using logger API -local str = rspamd_logger.slog('a=%1, b=%2, c=%3, d=%4, e=%5', a, b, c, d, e) +-- Using type specifiers +rspamd_logger.info('count=%1d, price=%.2f, name=%3', c, b, a) +-- Output: count=100, price=1.50, name=string + +-- Sequential formatting with types +rspamd_logger.info('int=%d, float=%.3f, str=%s', c, b, a) +-- Output: int=100, float=1.500, str=string +-- Create string using logger API +local str = rspamd_logger.slog('value=%1d, percent=%.1f%%', c, b) print(str) --- Output: a=string, b=1.50000, c=1, d={[1] = aa, [2] = 1, [3] = bb} e={[key]=value, [key2]=1.0} +-- Output: value=100, percent=1.5% */ /* Logger methods */ @@ -86,36 +99,56 @@ LUA_FUNCTION_DEF(logger, debug); /*** * @function logger.errx(fmt[, args) * Extended interface to make an error log message - * @param {string} fmt format string, arguments are encoded as % - * @param {any} args list of arguments to be replaced in % positions + * @param {string} fmt format string supporting: + * - Positional arguments: % (e.g., %1, %2, %3) + * - Sequential arguments: %s + * - Type specifiers: %d (int), %ud (unsigned), %f (float), %.Nf (float with precision) + * - Combined: %1d, %2f, %.2f + * @param {any} args list of arguments to be formatted */ LUA_FUNCTION_DEF(logger, errx); /*** * @function logger.warn(fmt[, args) * Extended interface to make a warning log message - * @param {string} fmt format string, arguments are encoded as % - * @param {any} args list of arguments to be replaced in % positions + * @param {string} fmt format string supporting: + * - Positional arguments: % (e.g., %1, %2, %3) + * - Sequential arguments: %s + * - Type specifiers: %d (int), %ud (unsigned), %f (float), %.Nf (float with precision) + * - Combined: %1d, %2f, %.2f + * @param {any} args list of arguments to be formatted */ LUA_FUNCTION_DEF(logger, warnx); /*** * @function logger.infox(fmt[, args) * Extended interface to make an informational log message - * @param {string} fmt format string, arguments are encoded as % - * @param {any} args list of arguments to be replaced in % positions + * @param {string} fmt format string supporting: + * - Positional arguments: % (e.g., %1, %2, %3) + * - Sequential arguments: %s + * - Type specifiers: %d (int), %ud (unsigned), %f (float), %.Nf (float with precision) + * - Combined: %1d, %2f, %.2f + * @param {any} args list of arguments to be formatted */ LUA_FUNCTION_DEF(logger, infox); /*** - * @function logger.infox(fmt[, args) - * Extended interface to make an informational log message - * @param {string} fmt format string, arguments are encoded as % - * @param {any} args list of arguments to be replaced in % positions + * @function logger.messagex(fmt[, args) + * Extended interface to make a notice log message + * @param {string} fmt format string supporting: + * - Positional arguments: % (e.g., %1, %2, %3) + * - Sequential arguments: %s + * - Type specifiers: %d (int), %ud (unsigned), %f (float), %.Nf (float with precision) + * - Combined: %1d, %2f, %.2f + * @param {any} args list of arguments to be formatted */ LUA_FUNCTION_DEF(logger, messagex); /*** * @function logger.debugx(fmt[, args) * Extended interface to make a debug log message - * @param {string} fmt format string, arguments are encoded as % - * @param {any} args list of arguments to be replaced in % positions + * @param {string} fmt format string supporting: + * - Positional arguments: % (e.g., %1, %2, %3) + * - Sequential arguments: %s + * - Type specifiers: %d (int), %ud (unsigned), %f (float), %.Nf (float with precision) + * - Combined: %1d, %2f, %.2f + * @param {any} args list of arguments to be formatted */ LUA_FUNCTION_DEF(logger, debugx); @@ -124,15 +157,19 @@ LUA_FUNCTION_DEF(logger, debugx); * Extended interface to make a debug log message * @param {string} module debug module * @param {task|cfg|pool|string} id id to log - * @param {string} fmt format string, arguments are encoded as % - * @param {any} args list of arguments to be replaced in % positions + * @param {string} fmt format string supporting type specifiers (%d, %ud, %f, %.Nf) + * @param {any} args list of arguments to be formatted */ LUA_FUNCTION_DEF(logger, debugm); /*** * @function logger.slog(fmt[, args) * Create string replacing percent params with corresponding arguments - * @param {string} fmt format string, arguments are encoded as % - * @param {any} args list of arguments to be replaced in % positions + * @param {string} fmt format string supporting: + * - Positional arguments: % (e.g., %1, %2, %3) + * - Sequential arguments: %s + * - Type specifiers: %d (int), %ud (unsigned), %f (float), %.Nf (float with precision) + * - Combined: %1d, %2f, %.2f + * @param {any} args list of arguments to be formatted * @return {string} string with percent parameters substituted */ LUA_FUNCTION_DEF(logger, slog); @@ -142,8 +179,8 @@ LUA_FUNCTION_DEF(logger, slog); * Extended interface to make a generic log message on any level * @param {number} log level as a number (see GLogLevelFlags enum for values) * @param {task|cfg|pool|string} id id to log - * @param {string} fmt format string, arguments are encoded as % - * @param {any} args list of arguments to be replaced in % positions + * @param {string} fmt format string supporting type specifiers (%d, %ud, %f, %.Nf) + * @param {any} args list of arguments to be formatted */ LUA_FUNCTION_DEF(logger, logx); @@ -280,6 +317,120 @@ lua_logger_char_safe(int t, unsigned int esc_type) return true; } +/* Format specifier types */ +enum lua_logger_format_type { + LUA_FMT_STRING = 0, /* %s - default, any type */ + LUA_FMT_INT, /* %d - signed integer */ + LUA_FMT_UINT, /* %ud - unsigned integer */ + LUA_FMT_DOUBLE, /* %f - double with optional precision */ +}; + +/* Format a number as integer */ +static gsize +lua_logger_out_int(lua_State *L, int pos, char *outbuf, gsize len, gboolean is_unsigned) +{ + if (lua_type(L, pos) == LUA_TNUMBER) { + lua_Number num = lua_tonumber(L, pos); + if (is_unsigned) { + guint64 uval = (guint64) num; + return rspamd_snprintf(outbuf, len, "%uL", uval); + } + else { + gint64 ival = (gint64) num; + return rspamd_snprintf(outbuf, len, "%L", ival); + } + } + else if (lua_type(L, pos) == LUA_TSTRING) { + /* Try to convert string to number */ + gsize slen; + const char *str = lua_tolstring(L, pos, &slen); + if (is_unsigned) { + guint64 uval; + if (rspamd_strtoul(str, slen, &uval)) { + return rspamd_snprintf(outbuf, len, "%uL", uval); + } + } + else { + gint64 ival; + if (rspamd_strtol(str, slen, &ival)) { + return rspamd_snprintf(outbuf, len, "%L", ival); + } + } + } + /* Fallback for non-numeric types */ + return rspamd_snprintf(outbuf, len, is_unsigned ? "0" : "0"); +} + +/* Format a number as double with precision */ +static gsize +lua_logger_out_double(lua_State *L, int pos, char *outbuf, gsize len, int precision) +{ + gsize r; + char *p; + + if (lua_type(L, pos) == LUA_TNUMBER) { + lua_Number num = lua_tonumber(L, pos); + if (precision >= 0) { + return rspamd_snprintf(outbuf, len, "%.*f", precision, (double) num); + } + else { + /* Default: smart formatting without trailing zeros */ + r = rspamd_snprintf(outbuf, len, "%.6f", (double) num); + /* Remove trailing zeros, but keep at least one digit after decimal point */ + if (r > 0 && outbuf[0] != '\0') { + p = outbuf + r - 1; + while (p > outbuf && *p == '0') { + p--; + } + /* Keep at least one digit after decimal point */ + if (*p == '.') { + p++; + } + p++; + *p = '\0'; + return p - outbuf; + } + return r; + } + } + else if (lua_type(L, pos) == LUA_TSTRING) { + /* Try to convert string to number */ + gsize slen; + const char *str = lua_tolstring(L, pos, &slen); + char *endptr; + double dval = g_ascii_strtod(str, &endptr); + if (endptr != str && (*endptr == '\0' || endptr == str + slen)) { + if (precision >= 0) { + return rspamd_snprintf(outbuf, len, "%.*f", precision, dval); + } + else { + /* Default: smart formatting without trailing zeros */ + r = rspamd_snprintf(outbuf, len, "%.6f", dval); + /* Remove trailing zeros, but keep at least one digit after decimal point */ + if (r > 0 && outbuf[0] != '\0') { + p = outbuf + r - 1; + while (p > outbuf && *p == '0') { + p--; + } + /* Keep at least one digit after decimal point */ + if (*p == '.') { + p++; + } + p++; + *p = '\0'; + return p - outbuf; + } + return r; + } + } + } + /* Fallback for non-numeric types */ + if (precision >= 0) { + return rspamd_snprintf(outbuf, len, "%.*f", precision, 0.0); + } + return rspamd_snprintf(outbuf, len, "0.0"); +} + #define LUA_MAX_ARGS 32 /* Gracefully handles argument mismatches by substituting missing args and noting extra args */ static glong @@ -294,17 +445,73 @@ lua_logger_log_format_str(lua_State *L, int offset, char *logbuf, gsize remain, unsigned int arg_num, cur_arg = 0, arg_max = lua_gettop(L) - offset; gboolean args_used[LUA_MAX_ARGS]; unsigned int used_args_count = 0; + enum lua_logger_format_type fmt_type; + int precision; memset(args_used, 0, sizeof(args_used)); while (remain > 1 && *fmt) { if (*fmt == '%') { ++fmt; + /* Check for %% (escaped percent) */ + if (*fmt == '%') { + *d++ = '%'; + ++fmt; + --remain; + continue; + } c = fmt; - if (*fmt == 's') { + fmt_type = LUA_FMT_STRING; + precision = -1; + + /* Check for precision specifier (%.Nf) */ + if (*fmt == '.') { + ++fmt; + precision = 0; + while ((digit = g_ascii_digit_value(*fmt)) >= 0) { + precision = precision * 10 + digit; + ++fmt; + } + /* Expect 'f' after precision */ + if (*fmt == 'f') { + fmt_type = LUA_FMT_DOUBLE; + ++fmt; + ++cur_arg; + } + else { + /* Invalid format, reset */ + fmt = c; + } + } + /* Check for format type specifiers */ + else if (*fmt == 's') { + fmt_type = LUA_FMT_STRING; ++fmt; ++cur_arg; } - else { + else if (*fmt == 'd') { + fmt_type = LUA_FMT_INT; + ++fmt; + ++cur_arg; + } + else if (*fmt == 'u') { + ++fmt; + if (*fmt == 'd') { + fmt_type = LUA_FMT_UINT; + ++fmt; + ++cur_arg; + } + else { + /* Just 'u' without 'd', treat as literal */ + fmt = c; + } + } + else if (*fmt == 'f') { + fmt_type = LUA_FMT_DOUBLE; + ++fmt; + ++cur_arg; + } + /* Check for positional argument (%) */ + else if (g_ascii_isdigit(*fmt)) { arg_num = 0; while ((digit = g_ascii_digit_value(*fmt)) >= 0) { ++fmt; @@ -317,6 +524,27 @@ lua_logger_log_format_str(lua_State *L, int offset, char *logbuf, gsize remain, } if (fmt > c) { + /* Check for type specifier after number */ + if (*fmt == 'd') { + fmt_type = LUA_FMT_INT; + ++fmt; + } + else if (*fmt == 'u') { + ++fmt; + if (*fmt == 'd') { + fmt_type = LUA_FMT_UINT; + ++fmt; + } + else { + --fmt; /* Backtrack */ + } + } + else if (*fmt == 'f') { + fmt_type = LUA_FMT_DOUBLE; + ++fmt; + } + /* else: default to LUA_FMT_STRING */ + /* Update the current argument */ cur_arg = arg_num; } @@ -328,8 +556,22 @@ lua_logger_log_format_str(lua_State *L, int offset, char *logbuf, gsize remain, r = rspamd_snprintf(d, remain, ""); } else { - /* Valid argument - output it */ - r = lua_logger_out(L, offset + cur_arg, d, remain, esc_type); + /* Valid argument - output it based on format type */ + switch (fmt_type) { + case LUA_FMT_INT: + r = lua_logger_out_int(L, offset + cur_arg, d, remain, FALSE); + break; + case LUA_FMT_UINT: + r = lua_logger_out_int(L, offset + cur_arg, d, remain, TRUE); + break; + case LUA_FMT_DOUBLE: + r = lua_logger_out_double(L, offset + cur_arg, d, remain, precision); + break; + case LUA_FMT_STRING: + default: + r = lua_logger_out(L, offset + cur_arg, d, remain, esc_type); + break; + } /* Track which arguments are used */ if (cur_arg <= LUA_MAX_ARGS && !args_used[cur_arg - 1]) { args_used[cur_arg - 1] = TRUE; @@ -343,8 +585,14 @@ lua_logger_log_format_str(lua_State *L, int offset, char *logbuf, gsize remain, continue; } - /* Copy % */ - --fmt; + /* Copy % if we couldn't parse a format specifier */ + if (fmt == c) { + --fmt; + } + else { + /* We parsed something but didn't match a valid format */ + --fmt; + } } *d++ = *fmt++; diff --git a/test/lua/unit/logger.lua b/test/lua/unit/logger.lua index c28d8bb09b..8b00ea3bb7 100644 --- a/test/lua/unit/logger.lua +++ b/test/lua/unit/logger.lua @@ -99,4 +99,129 @@ context("Logger unit tests", function() c[2], s)) end end) + + test("Logger type specifiers", function() + local log = require "rspamd_logger" + + -- Test %d (signed integer) + local int_cases = { + { '%d', '100', 100 }, + { '%d', '100', 100.5 }, -- Should truncate to integer + { '%d', '-42', -42 }, + { '%d', '0', 0 }, + { '%1d', '100', 100 }, + { 'count=%d', 'count=100', 100 }, + { 'count=%1d', 'count=100', 100 }, + { '%d items', '100 items', 100 }, + } + + for _, c in ipairs(int_cases) do + local s = log.slog(c[1], c[3]) + assert_equal(s, c[2], string.format("Int format test: '%s' doesn't match with '%s'", + c[2], s)) + end + + -- Test %ud (unsigned integer) + local uint_cases = { + { '%ud', '100', 100 }, + { '%1ud', '100', 100 }, + { 'size=%ud bytes', 'size=1024 bytes', 1024 }, + } + + for _, c in ipairs(uint_cases) do + local s = log.slog(c[1], c[3]) + assert_equal(s, c[2], string.format("Unsigned int format test: '%s' doesn't match with '%s'", + c[2], s)) + end + + -- Test %f (float) - smart formatting without trailing zeros + local float_cases = { + { '%f', '1.5', 1.5 }, + { '%f', '100.0', 100 }, + { '%f', '-42.75', -42.75 }, + { '%1f', '1.5', 1.5 }, + { 'pi=%f', 'pi=3.14', 3.14 }, + } + + for _, c in ipairs(float_cases) do + local s = log.slog(c[1], c[3]) + assert_equal(s, c[2], string.format("Float format test: '%s' doesn't match with '%s'", + c[2], s)) + end + + -- Test %.Nf (float with precision) + local precision_cases = { + { '%.2f', '1.50', 1.5 }, + { '%.3f', '3.145', 3.145 }, + { '%.1f', '100.0', 100 }, + { '%.0f', '42', 42.0 }, + { 'price=%.2f', 'price=19.99', 19.99 }, + } + + for _, c in ipairs(precision_cases) do + local s = log.slog(c[1], c[3]) + assert_equal(s, c[2], string.format("Precision format test: '%s' doesn't match with '%s'", + c[2], s)) + end + + -- Test mixed type specifiers + local mixed_type_cases = { + { 'count=%1d, price=%.2f, name=%3', 'count=100, price=1.50, name=string', 100, 1.5, 'string' }, + { '%d %f %s', '42 3.14 test', 42, 3.14, 'test' }, + { 'int=%d, float=%.3f, str=%s', 'int=100, float=1.500, str=hello', 100, 1.5, 'hello' }, + } + + for _, c in ipairs(mixed_type_cases) do + local s = log.slog(c[1], c[3], c[4], c[5]) + assert_equal(s, c[2], string.format("Mixed type format test: '%s' doesn't match with '%s'", + c[2], s)) + end + + -- Test type conversion from strings + local string_conversion_cases = { + { '%d', '42', '42' }, -- String to int + { '%f', '3.14', '3.14' }, -- String to float + { '%.2f', '3.14', '3.14' }, -- String to float with precision + } + + for _, c in ipairs(string_conversion_cases) do + local s = log.slog(c[1], c[3]) + assert_equal(s, c[2], string.format("String conversion test: '%s' doesn't match with '%s'", + c[2], s)) + end + + -- Test fallback for non-numeric types + local fallback_cases = { + { '%d', '0', nil }, -- nil should become 0 + { '%f', '0.0', nil }, -- nil should become 0.0 + { '%.2f', '0.00', nil }, -- nil with precision should become 0.00 + } + + for _, c in ipairs(fallback_cases) do + local s = log.slog(c[1], c[3]) + assert_equal(s, c[2], string.format("Fallback test: '%s' doesn't match with '%s'", + c[2], s)) + end + + -- Test %% escaping + local escape_cases = { + { '%%', '%' }, + { '100%%', '100%' }, + { 'price=%.2f%%', 'price=19.99%', 19.99 }, + { '%1 is %%d not %d', '100 is %d not 42', 100, 42 }, + } + + for _, c in ipairs(escape_cases) do + local s + if c[4] then + s = log.slog(c[1], c[3], c[4]) + elseif c[3] then + s = log.slog(c[1], c[3]) + else + s = log.slog(c[1]) + end + assert_equal(s, c[2], string.format("Escape test: '%s' doesn't match with '%s'", + c[2], s)) + end + end) end) \ No newline at end of file