]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Feature] Add type specifiers support to lua_logger 5668/head
authorVsevolod Stakhov <vsevolod@rspamd.com>
Mon, 6 Oct 2025 13:22:26 +0000 (14:22 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Mon, 6 Oct 2025 13:22:26 +0000 (14:22 +0100)
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.

src/lua/lua_logger.c
test/lua/unit/logger.lua

index 1d3858440d1c89586017d92c3f0a5cb1685e6ca3..bdfc218ebc7e32e5016b3767c7f3f6fce844589d 100644 (file)
@@ -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
--- %<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 */
@@ -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 %<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);
 
@@ -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 %<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);
@@ -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 %<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);
 
@@ -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 (%<number>) */
+                       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, "<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;
@@ -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++;
index c28d8bb09bf5ea1d98c04d0c6ea4ca171092a546..8b00ea3bb7b8db58392117276b413dd10c64e203 100644 (file)
@@ -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