local a = 'string'
local b = 1.5
-local c = 1
+local c = 100
local d = {
'aa',
1,
}
-- New extended interface
--- %<number> means numeric arguments and %s means the next argument
--- for example %1, %2, %s: %s would mean the third argument
-
+-- Positional arguments: %<number> (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 */
/***
* @function logger.errx(fmt[, args)
* Extended interface to make an error log message
- * @param {string} fmt format string, arguments are encoded as %<number>
- * @param {any} args list of arguments to be replaced in %<number> positions
+ * @param {string} fmt format string supporting:
+ * - Positional arguments: %<number> (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 %<number>
- * @param {any} args list of arguments to be replaced in %<number> positions
+ * @param {string} fmt format string supporting:
+ * - Positional arguments: %<number> (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 %<number>
- * @param {any} args list of arguments to be replaced in %<number> positions
+ * @param {string} fmt format string supporting:
+ * - Positional arguments: %<number> (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 %<number>
- * @param {any} args list of arguments to be replaced in %<number> positions
+ * @function logger.messagex(fmt[, args)
+ * Extended interface to make a notice log message
+ * @param {string} fmt format string supporting:
+ * - Positional arguments: %<number> (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 %<number>
- * @param {any} args list of arguments to be replaced in %<number> positions
+ * @param {string} fmt format string supporting:
+ * - Positional arguments: %<number> (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);
* 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 %<number>
- * @param {any} args list of arguments to be replaced in %<number> 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 %<number>
- * @param {any} args list of arguments to be replaced in %<number> positions
+ * @param {string} fmt format string supporting:
+ * - Positional arguments: %<number> (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);
* 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 %<number>
- * @param {any} args list of arguments to be replaced in %<number> 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);
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
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 (%<number>) */
+ else if (g_ascii_isdigit(*fmt)) {
arg_num = 0;
while ((digit = g_ascii_digit_value(*fmt)) >= 0) {
++fmt;
}
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;
}
r = rspamd_snprintf(d, remain, "<MISSING ARGUMENT>");
}
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;
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++;
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