]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Feature] Add fixed-point formatting to fpconv (#6061)
authorAlexander Moisseev <moiseev@mezonplus.ru>
Wed, 27 May 2026 08:09:39 +0000 (11:09 +0300)
committerGitHub <noreply@github.com>
Wed, 27 May 2026 08:09:39 +0000 (09:09 +0100)
* [Feature] Add fixed-point formatting to fpconv

- Add FPCONV_PRECISION_ALL sentinel for trim-trailing-zeros mode
  with compile-time guard (static_assert > 17 significant digits)
- Implement %.Nf rounding with carry (round_at, trim_trailing_zeros)
- Fix %.0f carry detection for numbers like 9.9 -> 10
- %f/%F/%g/%G use FPCONV_PRECISION_ALL instead of hardcoded literals
- Add C++ unit tests for fpconv precision and rounding

* [Fix] Fix carry overflow from fractional rounding in fpconv

- Add round_at_ex with carry_overflow flag to detect full carry
  that shifts digits and prepends '1'
- Fix offset<=0 branch (0.xxx): carry now correctly produces
  "1.0" instead of "0.1" (e.g. 0.96 → "1.0")
- Fix offset>0 branch (1.xxx-9.xxx): round_at called before
  copying to dest so integer digits are always fresh; carry
  correctly expands integer part (e.g. 9.96 → "10.0")

* [Fix] Fix wrong digits array index in fpconv offset<=0 rounding

Leading zeros are written by memset to dest, not stored in the
digits array. The rounding path incorrectly used orig_offset as
an index into digits for both round_at_ex position and memcpy
source, causing wrong output (e.g. 0.0123 → "0.02" instead of
"0.01") and potential out-of-bounds reads when ndigits < orig_offset

* [Rework] Extract fpconv fixed-point formatting into a separate shim layer

* [Fix] Fix rounding in fpconv_format emit_fixed_digits

Defect 1: Change >= to > when comparing leading zeros count with
precision, so that values like 0.005 with %.2f correctly round to
"0.01" instead of "0.00".

Defect 2: When carry occurs within the fractional part (e.g. 0.0999
with %.2f), emit "0.10" instead of incorrectly outputting "1.00".
Carry now distinguishes between crossing the integer boundary and
propagating within the fraction.

Also handle the case where precision equals the leading zeros count:
check the first significant digit directly for rounding instead of
calling round_at_ex with precision=0.

* [Refactor] Move fpconv_format shim from contrib/ to src/libutil/

---------

Co-authored-by: Vsevolod Stakhov <vsevolod@rspamd.com>
contrib/fpconv/fpconv.c
contrib/fpconv/fpconv.h
src/libutil/CMakeLists.txt
src/libutil/fpconv_format.c [new file with mode: 0644]
src/libutil/fpconv_format.h [new file with mode: 0644]
src/libutil/printf.c
test/rspamd_cxx_unit.cxx
test/rspamd_cxx_unit_fpconv.hxx [new file with mode: 0644]

index f8b601a55d9e0a5ae8a45d3eed4ffa78c47ce726..096deec3117ab5800bf45ef06e771af38756109b 100644 (file)
@@ -200,6 +200,27 @@ static int grisu2 (double d, char *digits, int *K) {
        return generate_digits (&w, &upper, &lower, digits, K);
 }
 
+int
+fpconv_grisu2 (double d, char digits[18], int *K, int *is_negative)
+{
+       uint64_t bits = get_dbits (d);
+       *is_negative = (bits & signmask) != 0;
+
+       if (d == 0.0) {
+               return FPCONV_GRISU_ZERO;
+       }
+
+       if ((bits & expmask) == expmask) {
+               if (bits & fracmask) {
+                       return FPCONV_GRISU_NAN;
+               }
+
+               return FPCONV_GRISU_INF;
+       }
+
+       return grisu2 (d, digits, K);
+}
+
 static inline int emit_integer (char *digits, int ndigits,
                                                                char *dest, int K, bool neg,
                                                                unsigned precision)
@@ -445,7 +466,7 @@ static int filter_special (double fp, char *dest, unsigned precision)
        return nchars;
 }
 
-int
+static int
 fpconv_dtoa (double d, char dest[FPCONV_BUFLEN],
                         unsigned precision, bool scientific)
 {
index 8c07c1368cacc1619f6c3351ec8b101909dbb921..4e778241931837a179f4ee23387cd16c70d3db55 100644 (file)
@@ -2,33 +2,20 @@
 #define FPCONV_H
 
 #define FPCONV_BUFLEN 32
-/* Fast and accurate double to string conversion based on Florian Loitsch's
- * Grisu-algorithm[1].
- *
- * Input:
- * fp -> the double to convert, dest -> destination buffer.
- * The generated string will never be longer than 24 characters.
- * Make sure to pass a pointer to at least 24 bytes of memory.
- * The emitted string will not be null terminated.
- *
- * Output:
- * The number of written characters.
- *
- * Exemplary usage:
- *
- * void print(double d)
- * {
- *      char buf[24 + 1] // plus null terminator
- *      int str_len = fpconv_dtoa(d, buf);
- *
- *      buf[str_len] = '\0';
- *      printf("%s", buf);
- * }
- *
- */
 
-int fpconv_dtoa(double fp, char dest[FPCONV_BUFLEN], unsigned precision,
-               bool scientific);
+/* Return codes from fpconv_grisu2 */
+#define FPCONV_GRISU_ZERO (-1)
+#define FPCONV_GRISU_INF  (-2)
+#define FPCONV_GRISU_NAN  (-3)
+
+/*
+ * Raw grisu2 decomposition for external formatters.
+ * digits[] receives the significant-digit characters (not null-terminated).
+ * *K receives the decimal exponent.
+ * *is_negative is set to 1 for negative values (including -0.0), 0 otherwise.
+ * Returns the number of significant digits (>0), or a FPCONV_GRISU_* code (<0).
+ */
+int fpconv_grisu2(double d, char digits[18], int *K, int *is_negative);
 
 #endif
 
index ced8fb6a7a34efb5472dda5fb3dab527c02d7307..df6da89f2c1abe1176f22547a99a61f8079d8fae 100644 (file)
@@ -4,6 +4,7 @@ SET(LIBRSPAMDUTILSRC
                                ${CMAKE_CURRENT_SOURCE_DIR}/libev_helper.c
                                ${CMAKE_CURRENT_SOURCE_DIR}/expression.c
                                ${CMAKE_CURRENT_SOURCE_DIR}/fstring.c
+                               ${CMAKE_CURRENT_SOURCE_DIR}/fpconv_format.c
                                ${CMAKE_CURRENT_SOURCE_DIR}/hash.c
                                ${CMAKE_CURRENT_SOURCE_DIR}/mem_pool.c
                                ${CMAKE_CURRENT_SOURCE_DIR}/printf.c
diff --git a/src/libutil/fpconv_format.c b/src/libutil/fpconv_format.c
new file mode 100644 (file)
index 0000000..cde2101
--- /dev/null
@@ -0,0 +1,551 @@
+#include <assert.h>
+#include <stdbool.h>
+#include <string.h>
+#include <sys/param.h>
+
+#include "fpconv_format.h"
+#include "contrib/fpconv/fpconv.h"
+
+/*
+ * Grisu2 produces at most 17 significant digits, so any explicit
+ * precision in [1..17] is safe to use for fixed-width padding.
+ * FPCONV_PRECISION_ALL (20) sits well above that range.
+ */
+static_assert(FPCONV_PRECISION_ALL > 17,
+               "FPCONV_PRECISION_ALL must exceed max significant digits of a double");
+
+#define absv(n) ((n) < 0 ? -(n) : (n))
+#define minv(a, b) ((a) < (b) ? (a) : (b))
+
+static inline int
+round_up_digits (char *digits, int ndigits)
+{
+       int i = ndigits - 1;
+
+       while (i >= 0) {
+               if (digits[i] < '9') {
+                       digits[i]++;
+                       return ndigits;
+               }
+               digits[i] = '0';
+               i--;
+       }
+
+       /*
+        * All digits carried (e.g. "99" -> "00"): shift right by one
+        * and prepend '1'.  The digits buffer from grisu2 has room
+        * for one extra character (at most 17 digits in an 18-byte
+        * array), so memmove is safe.
+        */
+       memmove(digits + 1, digits, ndigits);
+       digits[0] = '1';
+
+       return ndigits + 1;
+}
+
+/*
+ * Round the digits array at position `round_pos` (0-based).
+ * If digits[round_pos] >= '5', carry into digits[0..round_pos-1].
+ * Returns the new total number of digits (may increase by 1 on carry).
+ * `total` is the current number of valid digits in the array.
+ * If `carry_overflow` is non-NULL, sets it to 1 when a full carry
+ * shifts the digits right (prepending '1'), 0 otherwise.
+ */
+static inline int
+round_at_ex (char *digits, int total, int round_pos,
+               int *carry_overflow)
+{
+       if (round_pos >= total || digits[round_pos] < '5') {
+               if (carry_overflow) *carry_overflow = 0;
+               return total;
+       }
+
+       /* Round up: carry into digits[0..round_pos-1] */
+       if (round_pos == 0) {
+               digits[0] = '1';
+               if (carry_overflow) *carry_overflow = 0;
+               return 1;
+       }
+
+       int new_total = round_pos;
+       digits[round_pos] = '0';
+
+       int i = round_pos - 1;
+       while (i >= 0) {
+               if (digits[i] < '9') {
+                       digits[i]++;
+                       if (carry_overflow) *carry_overflow = 0;
+                       return new_total;
+               }
+               digits[i] = '0';
+               i--;
+       }
+
+       /* Full carry: shift right and prepend '1' */
+       memmove(digits + 1, digits, new_total);
+       digits[0] = '1';
+       if (carry_overflow) *carry_overflow = 1;
+       return new_total + 1;
+}
+
+static inline int
+round_at (char *digits, int total, int round_pos)
+{
+       return round_at_ex(digits, total, round_pos, NULL);
+}
+
+/*
+ * Trim trailing '0' characters from [start, start+len) and the preceding
+ * '.' if all fractional digits are removed.  Returns new length.
+ */
+static inline int
+trim_trailing_zeros (char *start, int len)
+{
+       if (len <= 0) {
+               return len;
+       }
+
+       char *p = start + len - 1;
+
+       while (p > start && *p == '0') {
+               p--;
+       }
+
+       if (*p == '.') {
+               p--;
+       }
+
+       return (p - start) + 1;
+}
+
+static inline int
+emit_integer (char *digits, int ndigits,
+               char *dest, int K, bool neg,
+               unsigned precision)
+{
+       char *d = dest;
+
+       memcpy (d, digits, ndigits);
+       d += ndigits;
+       memset (d, '0', K);
+       d += K;
+
+       if (precision == FPCONV_PRECISION_ALL) {
+               return d - dest;
+       }
+
+       precision = MIN(precision, FPCONV_BUFLEN - (ndigits + K + 1));
+
+       if (precision) {
+               *d++ = '.';
+               memset (d, '0', precision);
+               d += precision;
+       }
+
+       return d - dest;
+}
+
+static inline int
+emit_scientific_digits (char *digits, int ndigits,
+               char *dest, int K, bool neg,
+               unsigned precision, int exp)
+{
+       ndigits = minv(ndigits, 18 - neg);
+
+       int idx = 0;
+       dest[idx++] = digits[0];
+
+       if (ndigits > 1) {
+               dest[idx++] = '.';
+               memcpy(dest + idx, digits + 1, ndigits - 1);
+               idx += ndigits - 1;
+       }
+
+       dest[idx++] = 'e';
+
+       char sign = K + ndigits - 1 < 0 ? '-' : '+';
+       dest[idx++] = sign;
+
+       int cent = 0;
+
+       if (exp > 99) {
+               cent = exp / 100;
+               dest[idx++] = cent + '0';
+               exp -= cent * 100;
+       }
+       if (exp > 9) {
+               int dec = exp / 10;
+               dest[idx++] = dec + '0';
+               exp -= dec * 10;
+
+       }
+       else if (cent) {
+               dest[idx++] = '0';
+       }
+
+       dest[idx++] = exp % 10 + '0';
+
+       return idx;
+}
+
+static inline int
+emit_fixed_digits (char *digits, int ndigits,
+               char *dest, int K, bool neg,
+               unsigned precision, int exp)
+{
+       int offset = ndigits - absv(K), to_print;
+       bool trim = (precision == FPCONV_PRECISION_ALL);
+
+       /* fp < 1.0 -> write leading zero */
+       if (K < 0) {
+               if (offset <= 0) {
+                       if (precision && !trim) {
+                               if (-offset > (int)precision) {
+                                       /* Just print 0.[0]{precision} */
+                                       dest[0] = '0';
+                                       dest[1] = '.';
+                                       memset(dest + 2, '0', precision);
+
+                                       return precision + 2;
+                               }
+
+                               to_print = MAX(ndigits - offset, (int)precision);
+                       }
+                       else if (trim) {
+                               /*
+                                * FPCONV_PRECISION_ALL: emit all significant digits,
+                                * then trim trailing zeros.
+                                */
+                               to_print = ndigits - offset;
+
+                               if (to_print <= FPCONV_BUFLEN - 3) {
+                                       int orig_offset = -offset;
+                                       dest[0] = '0';
+                                       dest[1] = '.';
+                                       memset(dest + 2, '0', orig_offset);
+                                       memcpy(dest + orig_offset + 2, digits, ndigits);
+
+                                       return trim_trailing_zeros(dest,
+                                                       ndigits + 2 + orig_offset);
+                               }
+                               else {
+                                       return emit_scientific_digits(digits, ndigits,
+                                                       dest, K, neg, precision, exp);
+                               }
+                       }
+                       else {
+                               /*
+                                * precision == 0: print as rounded integer.
+                                */
+                               if (offset >= 0 && digits[0] >= '5') {
+                                       dest[0] = '1';
+                               }
+                               else {
+                                       dest[0] = '0';
+                               }
+
+                               return 1;
+                       }
+
+                       if (to_print <= FPCONV_BUFLEN - 3) {
+                               offset = -offset;
+
+                               if (precision) {
+                                       unsigned orig_offset = offset;
+                                       unsigned total_frac = precision;
+
+                                       precision -= offset;
+
+                                       if (precision == 0) {
+                                               /*
+                                                * All fractional digits are leading zeros.
+                                                * Check if the first significant digit rounds up.
+                                                */
+                                               if (digits[0] >= '5') {
+                                                       unsigned new_leading = orig_offset - 1;
+                                                       dest[0] = '0';
+                                                       dest[1] = '.';
+                                                       memset(dest + 2, '0', new_leading);
+                                                       dest[2 + new_leading] = '1';
+                                                       unsigned trailing = total_frac - new_leading - 1;
+
+                                                       if (trailing > 0) {
+                                                               memset(dest + 3 + new_leading, '0',
+                                                                               trailing);
+                                                       }
+
+                                                       return total_frac + 2;
+                                               }
+
+                                               dest[0] = '0';
+                                               dest[1] = '.';
+                                               memset(dest + 2, '0', total_frac);
+
+                                               return total_frac + 2;
+                                       }
+
+                                       if (precision <= (unsigned)ndigits) {
+                                               int carry = 0;
+
+                                               /* Round at the truncation point */
+                                               if (precision < (unsigned)ndigits) {
+                                                       ndigits = round_at_ex(digits, ndigits,
+                                                                       precision, &carry);
+                                               }
+
+                                               if (carry) {
+                                                       if (orig_offset == 0) {
+                                                               /*
+                                                                * Carry crossed to integer part
+                                                                * (e.g. 0.999 -> 1.00)
+                                                                */
+                                                               dest[0] = '1';
+                                                               dest[1] = '.';
+                                                               memset(dest + 2, '0', total_frac);
+                                                       }
+                                                       else {
+                                                               /*
+                                                                * Carry within fractional part
+                                                                * (e.g. 0.0999 -> 0.10)
+                                                                */
+                                                               unsigned new_leading = orig_offset - 1;
+                                                               dest[0] = '0';
+                                                               dest[1] = '.';
+                                                               memset(dest + 2, '0', new_leading);
+                                                               memcpy(dest + 2 + new_leading,
+                                                                               digits, ndigits);
+                                                               unsigned emitted = new_leading + ndigits;
+
+                                                               if (emitted < total_frac) {
+                                                                       memset(dest + 2 + emitted, '0',
+                                                                                       total_frac - emitted);
+                                                               }
+                                                       }
+
+                                                       return total_frac + 2;
+                                               }
+
+                                               dest[0] = '0';
+                                               dest[1] = '.';
+                                               memset(dest + 2, '0', orig_offset);
+                                               memcpy(dest + 2 + orig_offset,
+                                                               digits, precision);
+
+                                               return total_frac + 2;
+                                       }
+                                       else {
+                                               /* Expand */
+                                               dest[0] = '0';
+                                               dest[1] = '.';
+                                               memset(dest + 2, '0', offset);
+                                               memcpy(dest + offset + 2, digits, ndigits);
+                                               precision -= ndigits;
+                                               memset(dest + offset + 2 + ndigits, '0', precision);
+
+                                               return ndigits + 2 + offset + precision;
+                                       }
+                               }
+                               else {
+                                       dest[0] = '0';
+                                       dest[1] = '.';
+                                       memset(dest + 2, '0', offset);
+                                       memcpy(dest + offset + 2, digits, ndigits);
+                               }
+
+                               return ndigits + 2 + offset;
+                       }
+                       else {
+                               return emit_scientific_digits (digits, ndigits, dest, K, neg, precision, exp);
+                       }
+               }
+               else {
+                       /*
+                        * offset > 0: fp is 1.xxx .. 9.xxx
+                        */
+                       if (offset > 0 && ndigits <= FPCONV_BUFLEN - 3) {
+                               char *d = dest;
+
+                               if (precision == 0) {
+                                       if (offset < ndigits &&
+                                       digits[offset] >= '5') {
+                                               int new_ndigits = round_at(digits,
+                                                               ndigits, offset);
+
+                                               memcpy(d, digits, new_ndigits);
+                                               return new_ndigits;
+                                       }
+
+                                       memcpy(d, digits, offset);
+                                       return offset;
+                               }
+
+                               ndigits -= offset;
+
+                               if (precision) {
+                                       if (!trim && (unsigned)ndigits >= precision) {
+                                               int round_pos = offset + precision;
+                                               int orig_offset = offset;
+                                               int carry = 0;
+
+                                               ndigits = round_at_ex(digits,
+                                                               ndigits + offset, round_pos,
+                                                               &carry);
+
+                                               if (carry) {
+                                                       int new_int = orig_offset + 1;
+                                                       memcpy(d, digits, new_int);
+                                                       d += new_int;
+                                                       *d++ = '.';
+                                                       int frac_avail = ndigits - new_int;
+                                                       if (frac_avail > 0) {
+                                                               memcpy(d, digits + new_int,
+                                                                               frac_avail);
+                                                               d += frac_avail;
+                                                               precision -= frac_avail;
+                                                       }
+                                                       memset(d, '0', precision);
+                                                       d += precision;
+
+                                                       return d - dest;
+                                               }
+
+                                               memcpy(d, digits, orig_offset);
+                                               d += orig_offset;
+                                               *d++ = '.';
+                                               memcpy(d, digits + orig_offset, precision);
+                                               d += precision;
+
+                                               return d - dest;
+                                       }
+                                       else if (trim) {
+                                               memcpy(d, digits, offset);
+                                               d += offset;
+                                               *d++ = '.';
+                                               memcpy(d, digits + offset, ndigits);
+                                               d += ndigits;
+
+                                               int total_len = d - dest;
+                                               return trim_trailing_zeros(dest, total_len);
+                                       }
+                                       else {
+                                               memcpy(d, digits, offset);
+                                               d += offset;
+                                               *d++ = '.';
+                                               memcpy(d, digits + offset, ndigits);
+                                               precision -= ndigits;
+                                               d += ndigits;
+
+                                               if ((d - dest) + precision <= FPCONV_BUFLEN) {
+                                                       memset (d, '0', precision);
+                                                       d += precision;
+                                               }
+                                               else {
+                                                       memset (d, '0', FPCONV_BUFLEN - (d - dest));
+                                                       d += FPCONV_BUFLEN - (d - dest);
+                                               }
+                                       }
+                               }
+                               else {
+                                       memcpy(d, digits, offset);
+                                       d += offset;
+                                       *d++ = '.';
+                                       memcpy(d, digits + offset, ndigits);
+                                       d += ndigits;
+                               }
+
+                               return d - dest;
+                       }
+               }
+       }
+
+       return emit_scientific_digits (digits, ndigits, dest, K, neg, precision, exp);
+}
+
+static int
+emit_digits (char *digits, int ndigits, char *dest, int K, bool neg,
+               unsigned precision, bool scientific)
+{
+       int exp = absv(K + ndigits - 1);
+
+       /* write plain integer */
+       if (K >= 0 && (exp < (ndigits + 7))) {
+               return emit_integer (digits, ndigits, dest, K, neg, precision);
+       }
+
+       /* write decimal w/o scientific notation */
+       if (!scientific || (K < 0 && (K > -7 || exp < 4))) {
+               return emit_fixed_digits (digits, ndigits, dest, K, neg, precision, exp);
+       }
+
+       return emit_scientific_digits (digits, ndigits, dest, K, neg, precision, exp);
+}
+
+static int
+format_special (int code, char *dest, unsigned precision, bool is_negative)
+{
+       char *d = dest;
+
+       if (code == FPCONV_GRISU_ZERO) {
+               if (is_negative) {
+                       *d++ = '-';
+               }
+               *d++ = '0';
+
+               if (precision && precision != FPCONV_PRECISION_ALL) {
+                       *d++ = '.';
+                       memset(d, '0', precision);
+                       d += precision;
+               }
+
+               return d - dest;
+       }
+
+       if (code == FPCONV_GRISU_NAN) {
+               dest[0] = 'n';
+               dest[1] = 'a';
+               dest[2] = 'n';
+               return 3;
+       }
+
+       /* FPCONV_GRISU_INF */
+       if (is_negative) {
+               dest[0] = '-';
+               dest[1] = 'i';
+               dest[2] = 'n';
+               dest[3] = 'f';
+               return 4;
+       }
+
+       dest[0] = 'i';
+       dest[1] = 'n';
+       dest[2] = 'f';
+       return 3;
+}
+
+int
+fpconv_format_dtoa (double d, char dest[FPCONV_BUFLEN],
+               unsigned precision, bool scientific)
+{
+       if (precision > FPCONV_BUFLEN - 5) {
+               precision = FPCONV_BUFLEN - 5;
+       }
+
+       char digits[18];
+       int K = 0, is_negative = 0;
+       int ndigits = fpconv_grisu2(d, digits, &K, &is_negative);
+
+       if (ndigits < 0) {
+               return format_special(ndigits, dest, precision, is_negative);
+       }
+
+       int str_len = 0;
+       if (is_negative) {
+               dest[0] = '-';
+               str_len = 1;
+       }
+
+       str_len += emit_digits(digits, ndigits, dest + str_len, K,
+                       is_negative, precision, scientific);
+
+       return str_len;
+}
diff --git a/src/libutil/fpconv_format.h b/src/libutil/fpconv_format.h
new file mode 100644 (file)
index 0000000..ddcfb9b
--- /dev/null
@@ -0,0 +1,48 @@
+#ifndef FPCONV_FORMAT_H
+#define FPCONV_FORMAT_H
+
+#include "contrib/fpconv/fpconv.h"
+
+/*
+ * Sentinel precision: emit all significant digits, then trim trailing zeros.
+ * Any value in [1..17] is a valid fixed-width precision (double has at most
+ * 17 significant digits).  20 sits safely above that range.
+ * Do NOT use 20 as a fixed-width precision.
+ */
+#define FPCONV_PRECISION_ALL  20
+
+/*
+ * Format a double into dest[] with fixed-point or scientific notation.
+ *
+ * Input:
+ * d    -> the double to convert, dest -> destination buffer.
+ * Make sure to pass a pointer to at least FPCONV_BUFLEN bytes of memory.
+ * The emitted string will not be null terminated.
+ *
+ * Output:
+ * The number of written characters.
+ *
+ * precision:
+ *   FPCONV_PRECISION_ALL (20)  - trim mode: shortest accurate representation
+ *   0                           - round to integer (e.g. 1.6 -> "2")
+ *   N (1..17)                   - fixed N decimal places with rounding
+ *
+ * scientific:
+ *   false  - fixed-point (%f / %F)
+ *   true   - shortest notation, may use scientific (%g / %G)
+ *
+ * Exemplary usage:
+ *
+ * void print(double d)
+ * {
+ *      char buf[FPCONV_BUFLEN + 1]; // plus null terminator
+ *      int str_len = fpconv_format_dtoa(d, buf, FPCONV_PRECISION_ALL, false);
+ *
+ *      buf[str_len] = '\0';
+ *      printf("%s", buf);
+ * }
+ */
+int fpconv_format_dtoa(double d, char dest[FPCONV_BUFLEN],
+               unsigned precision, bool scientific);
+
+#endif
index 82eab0ab8cbcdccc689505fb9bd0bb792e1513b0..35b1c15e4c650c2ed131980f6483a521afe48a9d 100644 (file)
@@ -40,7 +40,7 @@
 
 #include "printf.h"
 #include "str_util.h"
-#include "contrib/fpconv/fpconv.h"
+#include "fpconv_format.h"
 
 /**
  * From FreeBSD libutil code
@@ -663,6 +663,7 @@ glong rspamd_vprintf_common(rspamd_printf_append_func func,
                        bytes = 0;
                        humanize = 0;
                        frac_width = 0;
+                       bool frac_specified = false;
                        slen = -1;
 
                        while (*fmt >= '0' && *fmt <= '9') {
@@ -716,6 +717,7 @@ glong rspamd_vprintf_common(rspamd_printf_append_func func,
                                        continue;
                                case '.':
                                        fmt++;
+                                       frac_specified = true;
 
                                        if (*fmt == '*') {
                                                d = (int) va_arg(args, int);
@@ -979,7 +981,9 @@ glong rspamd_vprintf_common(rspamd_printf_append_func func,
 
                        case 'f':
                                f = (double) va_arg(args, double);
-                               slen = fpconv_dtoa(f, dtoabuf, frac_width, false);
+                               slen = fpconv_format_dtoa(f, dtoabuf,
+                                               frac_specified ? frac_width : FPCONV_PRECISION_ALL,
+                                               false);
 
                                RSPAMD_PRINTF_APPEND(dtoabuf, slen);
 
@@ -987,14 +991,18 @@ glong rspamd_vprintf_common(rspamd_printf_append_func func,
 
                        case 'g':
                                f = (double) va_arg(args, double);
-                               slen = fpconv_dtoa(f, dtoabuf, 0, true);
+                               slen = fpconv_format_dtoa(f, dtoabuf,
+                                               frac_specified ? frac_width : FPCONV_PRECISION_ALL,
+                                               true);
                                RSPAMD_PRINTF_APPEND(dtoabuf, slen);
 
                                continue;
 
                        case 'F':
                                f = (double) va_arg(args, long double);
-                               slen = fpconv_dtoa(f, dtoabuf, frac_width, false);
+                               slen = fpconv_format_dtoa(f, dtoabuf,
+                                               frac_specified ? frac_width : FPCONV_PRECISION_ALL,
+                                               false);
 
                                RSPAMD_PRINTF_APPEND(dtoabuf, slen);
 
@@ -1002,7 +1010,9 @@ glong rspamd_vprintf_common(rspamd_printf_append_func func,
 
                        case 'G':
                                f = (double) va_arg(args, long double);
-                               slen = fpconv_dtoa(f, dtoabuf, 0, true);
+                               slen = fpconv_format_dtoa(f, dtoabuf,
+                                               frac_specified ? frac_width : FPCONV_PRECISION_ALL,
+                                               true);
                                RSPAMD_PRINTF_APPEND(dtoabuf, slen);
 
                                continue;
index 4f2713838d528f80a7d1d2e14fefb91f23837a90..4ab10c73c795cc7f489d8176ea3cd7576b49e4b7 100644 (file)
@@ -37,6 +37,7 @@
 #include "rspamd_cxx_unit_upstream_srv.hxx"
 #include "rspamd_cxx_unit_multipart.hxx"
 #include "rspamd_cxx_unit_settings_merge.hxx"
+#include "rspamd_cxx_unit_fpconv.hxx"
 
 static gboolean verbose = false;
 static const GOptionEntry entries[] =
diff --git a/test/rspamd_cxx_unit_fpconv.hxx b/test/rspamd_cxx_unit_fpconv.hxx
new file mode 100644 (file)
index 0000000..e00d2bd
--- /dev/null
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2026 Alexander Moisseev
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef RSPAMD_RSPAMD_CXX_UNIT_FPCONV_HXX
+#define RSPAMD_RSPAMD_CXX_UNIT_FPCONV_HXX
+
+#define DOCTEST_CONFIG_IMPLEMENTATION_IN_DLL
+#include "doctest/doctest.h"
+
+extern "C" {
+#include "libutil/fpconv_format.h"
+}
+
+#include <string>
+#include <vector>
+#include <utility>
+#include <cstring>
+
+TEST_SUITE("fpconv")
+{
+       static std::string dtoa(double d, unsigned precision = 0, bool scientific = false)
+       {
+               char buf[FPCONV_BUFLEN];
+               int len = fpconv_format_dtoa(d, buf, precision, scientific);
+               return std::string(buf, len);
+       }
+
+       TEST_CASE("fpconv_dtoa basic integers")
+       {
+               CHECK(dtoa(0.0) == "0");
+               CHECK(dtoa(1.0) == "1");
+               CHECK(dtoa(42.0) == "42");
+               CHECK(dtoa(123456.0) == "123456");
+       }
+
+       TEST_CASE("fpconv_dtoa precision=0 rounding (fixed-point)")
+       {
+               std::vector<std::pair<double, std::string>> cases{
+                       {1.001, "1"},
+                       {1.4, "1"},
+                       {1.5, "2"},
+                       {1.6, "2"},
+                       {1.999, "2"},
+                       {9.9, "10"},
+                       {9.5, "10"},
+                       {9.4, "9"},
+                       {0.1, "0"},
+                       {0.4, "0"},
+                       {0.5, "1"},
+                       {0.9, "1"},
+                       {0.001, "0"},
+                       {60.0, "60"},
+                       {59.999, "60"},
+                       /* Negative numbers (sign preserved, consistent with libc %.0f) */
+                       {-1.5, "-2"},
+                       {-1.4, "-1"},
+                       {-0.5, "-1"},
+                       {-0.4, "-0"}, /* "-0" is correct: sign is preserved */
+                       {-9.9, "-10"},
+               };
+
+               for (const auto &c: cases) {
+                       SUBCASE(("round %.0f for " + std::to_string(c.first)).c_str())
+                       {
+                               auto result = dtoa(c.first, 0);
+                               CHECK(result == c.second);
+                       }
+               }
+       }
+
+       TEST_CASE("fpconv_dtoa precision=0 offset boundary (0.4 vs 0.5)")
+       {
+               /*
+                * When offset >= 0, digits[0] is the tenths-place digit;
+                * >= '5' rounds up.  When offset < 0 (e.g. 0.05, K=-2),
+                * the value is < 0.1 and rounds to "0".
+                */
+               CHECK(dtoa(0.499, 0) == "0");
+               CHECK(dtoa(0.5, 0) == "1");
+               CHECK(dtoa(0.5001, 0) == "1");
+               CHECK(dtoa(0.05, 0) == "0");
+               CHECK(dtoa(0.005, 0) == "0");
+       }
+
+       TEST_CASE("fpconv_dtoa precision=1 rounding")
+       {
+               CHECK(dtoa(1.001, 1) == "1.0");
+               CHECK(dtoa(1.04, 1) == "1.0");
+               CHECK(dtoa(0.04, 1) == "0.0");
+       }
+
+       TEST_CASE("fpconv_dtoa precision=2 rounding")
+       {
+               CHECK(dtoa(1.001, 2) == "1.00");
+               CHECK(dtoa(0.004, 2) == "0.00");
+       }
+
+       TEST_CASE("fpconv_dtoa scientific notation")
+       {
+               /* Verify leading digit and 'e' presence */
+               auto r1 = dtoa(1e20, 0, true);
+               CHECK(r1.substr(0, 1) == "1");
+               CHECK(r1.find('e') != std::string::npos);
+
+               auto r2 = dtoa(1.5e-10, 0, true);
+               CHECK(r2.substr(0, 1) == "1");
+               CHECK(r2.find('e') != std::string::npos);
+       }
+
+       TEST_CASE("fpconv_dtoa precision=20 (all significant digits, rspamd %f default)")
+       {
+               /* Trim mode: emit shortest accurate representation */
+               CHECK(dtoa(1.001, 20) == "1.001");
+               CHECK(dtoa(0.5, 20) == "0.5");
+               CHECK(dtoa(0.0, 20) == "0");
+               CHECK(dtoa(1.0, 20) == "1");
+               /* Exact digit count depends on Grisu2 shortest representation */
+               CHECK(dtoa(1.0 / 3.0, 20) == "0.3333333333333333");
+       }
+
+       TEST_CASE("fpconv_dtoa special values")
+       {
+               CHECK(dtoa(0.0) == "0");
+               CHECK(dtoa(1.0 / 0.0) == "inf");
+               CHECK(dtoa(-1.0 / 0.0) == "-inf");
+               std::string nan_result = dtoa(0.0 / 0.0);
+               CHECK(nan_result == "nan");
+       }
+
+       TEST_CASE("fpconv_dtoa rounding when leading zeros equal precision")
+       {
+               /*
+                * Defect 1 regression: -offset == precision.
+                * The first significant digit >= '5' must round up.
+                */
+               CHECK(dtoa(0.005, 2) == "0.01");
+               CHECK(dtoa(0.004, 2) == "0.00");
+               CHECK(dtoa(0.06, 1) == "0.1");
+               CHECK(dtoa(0.04, 1) == "0.0");
+               CHECK(dtoa(0.0005, 3) == "0.001");
+               CHECK(dtoa(0.0004, 3) == "0.000");
+       }
+
+       TEST_CASE("fpconv_dtoa carry within fractional part")
+       {
+               /*
+                * Defect 2 regression: carry propagates within the
+                * fractional part without crossing the integer boundary.
+                */
+               CHECK(dtoa(0.0999, 2) == "0.10");
+               CHECK(dtoa(0.095, 2) == "0.10");
+               CHECK(dtoa(0.094, 2) == "0.09");
+               CHECK(dtoa(0.00999, 3) == "0.010");
+               CHECK(dtoa(0.0095, 3) == "0.010");
+               /* Carry that DOES cross to integer (for contrast) */
+               CHECK(dtoa(0.999, 2) == "1.00");
+               CHECK(dtoa(0.995, 2) == "1.00");
+       }
+}
+
+#endif