Okay, here is an example program.
Do note that this is not something I can suggest as-is, and is just work-in-progress. I'm very happy to hear ideas and suggestions for improvement, too, but do note that this is mainly intended to show how my above examples would work in practice. It is all licensed under CC0-1.0 (i.e. public domain, do as you wish, just don't try to sue me for any damages you cause if you do use it), too.
First, here is the example C program, example.c that you can compile and run under any OS. (Well, I used Linux, but it should compile and run everywhere.)
// SPDX-License-Identifier: CC0-1.0
//
#include <stdlib.h>
#include <stdio.h>
#include "writeonly-buffer.h"
#include "int-format.h"
int main(void)
{
/* Define a buffer, */
unsigned char mybuf_data[50];
/* and declare the write-only buffer that can write into it. */
writeonly_buffer mybuf = WRITEONLY_BUFFER(mybuf_data, sizeof mybuf_data);
/* Define a fixed-point decimal integer formatting, */
const int_format myfix_format = {
.flags = INT_FORMAT_USE_PLUS | INT_FORMAT_POINT,
.min_value = -999999,
.max_value = +999999,
.width = 0, /* Not set, so whatever minimum width is needed */
.decimals = 3,
.point_char = '.'
};
/* and check it is valid. */
if (int_format_invalid(&myfix_format)) {
fprintf(stderr, "Oops, myfix_format is invalid.\n");
return EXIT_FAILURE;
}
/* Format something. */
int x = 45267;
int y = -13;
format_string(&mybuf, "x is ");
format_int(&mybuf, x, NULL);
format_string(&mybuf, " and y is ");
format_int(&mybuf, y, &myfix_format);
/* Finalise the buffer. */
int len = writeonly_buffer_finish(&mybuf);
/* Check for errors. */
if (len < 0) {
fprintf(stderr, "writeonly_buffer_finish() failed: error %d.\n", len);
return EXIT_FAILURE;
} else
if (writeonly_buffer_state(&mybuf)) {
fprintf(stderr, "writeonly_buffer_state() reported %d.\n", writeonly_buffer_state(&mybuf));
return EXIT_FAILURE;
}
/* Show what we have. */
printf("Constructed a string containing %d characters: \"%s\".\n", len, mybuf_data);
return EXIT_SUCCESS;
}
The format_type() calls in the middle are the salient point, as well as the definition of the fixed point integer formatting options (which I named spec, because I realized "specification" describes its purpose better than "options") just preceding it. Note that I omitted the status checks from the formatting themselves, and instead moved it to the writeonly_buffer_finish().
The fixed point decimal integer type means that the integer value represents the same fixed point number with the decimal point omitted. Because it only requires dropping in the decimal point at the needed spot, I folded these into the same formatting facility.
You can also play with the .width and .flags, especially INT_FORMAT_ constants in the last file, to see how you can use that very same formatter to format integers to a specific number of characters with leading spaces, padded with zeroes with the sign on the extreme left, including + sign for positive numbers, the .min_value and .max_value clamping, and so on.
Just note that if you ask it to do the impossible, like show 6 decimals but keep width down to 5 characters, it will do wonky output. I was too lazy to implement the width checks in int_format_valid(), which would be responsible for checking that the formatting choices are acceptable.
Now, the writeonly_buffer_state() will report if the buffer was not large enough to format everything we wanted to. The program will report an error in that case, but a more sensible program or embedded firmware could do the formatting in a loop, and just move the head,tail part so that no matter how small the buffer is, we eventually get all of the formatted content. Sure, it is slower than just dynamically allocating a buffer large enough, but remember, we're talking about stuff intended for very memory-constrained environments, and the ability to work even with very small buffers may come in useful!
So, let's look at that write-only buffer stuff next. writeonly-buffer.h:
// SPDX-License-Identifier: CC0-1.0
//
#ifndef WRITEONLY_BUFFER_H
#define WRITEONLY_BUFFER_H
#include <limits.h>
typedef struct {
unsigned int state;
int pos;
int head;
int tail;
unsigned char *data;
} writeonly_buffer;
#define WRITEONLY_BUFFER(dataref, size) \
{ .state = 0, \
.pos = 0, \
.head = 0, \
.tail = (size), \
.data = (dataref) }
#define WRITEONLY_BUFFER_STATE_BEFORE 1 /* Data store attempt before head */
#define WRITEONLY_BUFFER_STATE_AFTER 2 /* Data store attempt at or after tail */
#define WRITEONLY_BUFFER_STATE_OVERFLOW 4 /* pos wraparound or limit exceeded */
static inline unsigned int writeonly_buffer_state(writeonly_buffer *wo)
{
return (wo) ? wo->state : 0;
}
static inline int writeonly_buffer_pos(writeonly_buffer *wo)
{
return (wo) ? wo->pos : -1;
}
static inline void writeonly_buffer_commit(writeonly_buffer *wo, int pos)
{
if (!wo)
return;
else
if (pos < wo->pos)
wo->state |= WRITEONLY_BUFFER_STATE_OVERFLOW;
else
wo->pos = pos;
}
static inline void writeonly_buffer_set(writeonly_buffer *wo, int pos, int ch)
{
if (!wo)
return;
else
if (pos < wo->head) {
wo->state |= WRITEONLY_BUFFER_STATE_BEFORE;
return;
} else
if (pos >= wo->tail) {
wo->state |= WRITEONLY_BUFFER_STATE_AFTER;
return;
} else {
wo->data[pos - wo->head] = (unsigned char)ch;
return;
}
}
static inline int writeonly_buffer_finish(writeonly_buffer *wo)
{
if (!wo)
return -1; /* No buffer specified */
if (wo->state & WRITEONLY_BUFFER_STATE_OVERFLOW)
return -2; /* Buffer len (position) overflow */
/* Add string-terminating NUL char */
if (wo->pos >= wo->head && wo->pos < wo->tail)
wo->data[wo->pos - wo->head] = '\0';
/* Return the length of the data emitted to the buffer. */
return wo->pos;
}
/*
* String and single-character formatters.
*/
__attribute__((unused))
static void format_string(writeonly_buffer *wo, const char *src)
{
/* Nothing to add? */
if (!src || !*src)
return;
int pos = writeonly_buffer_pos(wo);
while (*src)
writeonly_buffer_set(wo, pos++, *(src++));
writeonly_buffer_commit(wo, pos);
}
__attribute__((unused))
static void format_char(writeonly_buffer *wo, int ch)
{
/* Nothing to add? */
if (ch <= 0 || ch > UCHAR_MAX)
return;
int pos = writeonly_buffer_pos(wo);
writeonly_buffer_set(wo, pos++, ch);
writeonly_buffer_commit(wo, pos);
}
#endif /* WRITEONLY_BUFFER_H */
The writeonly_buffer structure near the beginning is the key here.
I probably should have named the pos field the len field, because it indicates where the current string construction point is. It is updated by a call to writeonly_buffer_commit(), specifying the new position/length. data points to the current real data buffer (window, not a complete buffer), where the (tail-head) char positions starting at position head are stored at. When data is the entire buffer, then head is zero, and tail is the length of that buffer. That's what the WRITEONLY_BUFFER() macro does for you: initializes the structure members that way, zeroing the initial position/length.
The state member is a bit cookie tracking things related to how the buffer was accessed. If someone tries to commit the buffer backwards, the bits set in WRITEONLY_BUFFER_STATE_OVERFLOW will be set in state (currently, bit 2, value 2²=4). If someone tries to set already flushed buffer data (i.e., data prior to current position/length), then WRITEONLY_BUFFER_STATE_BEFORE gets set. If someone tries to set buffer data past the current window (thus indicating that a larger buffer is needed), WRITEONLY_BUFFER_STATE_AFTER gets set.
(So, when using a smaller buffer than the stuff we want to format, our initial formatting will finish with WRITEONLY_BUFFER_STATE_AFTER. We write out the buffered data, set head to what tail was, and add the buffer size to tail, and do the formatting calls again. We now expect to see WRITEONLY_BUFFER_STATE_BEFORE. When WRITEONLY_BUFFER_STATE_AFTER is no longer set, we have the last (pos-head) chars in the buffer. When we have written those out, we're fully done. This way does need the formatting to be wrapped inside a do..while loop, where the loop condition function is one that writes the buffered data out, and only lets the loop exit when all of the formatted data is printed. I can show a separate example of that if you want, but I haven't yet even verified this works correctly...)
The __attribute__((unused)) just tells the compiler to not complain if one of the helper functions are not used.
Note that the inline here is purely for us humans; the compiler ignores it. I use static inline for helper/accessor type trivial functions, and static for local functions. It helps me think about the functions in an organized manner.
There are a lot of safety checks, but that is intentional. Making sure that only the valid parts of the buffer is accessed is worth the extra cost; even passing NULL pointers should be absolutely safe.
The writeonly_buffer_set() function is the one formatters will use to set any character in the logical buffer, in whatever order they want. When they have "written" a chunk, they then call writeonly_buffer_commit() to set the position/length they think should be now completed.
The buffer is write-only, because we cannot support access to already written/set data without keeping it in memory. That's just something we need to deal with, that's all.
Finally, let's take a look at how the format_int() is implemented. int-format.h:
// SPDX-License-Identifier: CC0-1.0
//
#ifndef INT_FORMAT_H
#define INT_FORMAT_H
#include <limits.h>
#include "writeonly-buffer.h"
#define INT_FORMAT_PREPAD 1 /* Padding before sign */
#define INT_FORMAT_MIDPAD 3 /* Padding between sign and digits */
#define INT_FORMAT_POSTPAD 2 /* Padding after digits */
#define INT_FORMAT_PADDING 3 /* Padding selection mask */
#define INT_FORMAT_OMIT_MINUS 4 /* Omit '-' sign even if negative */
#define INT_FORMAT_USE_PLUS 8 /* Use '+' sign if positive */
#define INT_FORMAT_POINT 16 /* Add decimal point */
typedef struct {
unsigned int flags; /* INT_FORMAT_ flags */
int min_value; /* Minimum value for clamping, inclusive */
int max_value; /* Maximum value for clamping, inclusive */
signed char width; /* Formatted total width */
signed char decimals; /* Number of fractional digits */
unsigned char padding_char; /* Padding character */
unsigned char point_char; /* Decimal point character */
} int_format;
static const int_format default_int_format = {
.flags = 0, /* No padding, signed integers, only use - if negative, no decimal point */
.min_value = INT_MIN, /* No clamping */
.max_value = INT_MAX, /* No clamping */
.width = 0, /* Unspecified */
.decimals = 0, /* None */
.padding_char = ' ', /* Default padding would be with spaces */
.point_char = '.', /* Default decimal point is '.' */
};
static int int_format_invalid(const int_format *spec) {
/* TODO: Verify sanity of formatting spec */
(void)spec; /* For now, just silence any warnings about unused parameters... */
return 0;
}
static inline int uint_decimal_digits(unsigned int value)
{
int digits = 1;
/* TODO: Implement more efficient way, e.g. an if tree. */
while (value >= 1000) {
value /= 1000;
digits += 3;
}
while (value >= 10) {
value /= 10;
digits += 1;
}
return digits;
}
static void format_int(writeonly_buffer *wo, int value, const int_format *spec)
{
/* Position in buffer. */
int pos = writeonly_buffer_pos(wo);
/* We can drop out, if there is no buffer to write to. */
if (pos < 0)
return;
/* If NULL spec, we use the default integer format. */
if (!spec)
spec = &default_int_format;
/* Apply clamping. */
if (value > spec->max_value)
value = spec->max_value;
if (value < spec->min_value)
value = spec->min_value;
/* The magnitude of the value to be formatted. */
unsigned int absval = (value < 0) ? (unsigned int)(-value) : (unsigned int)value;
/* Count how many decimal digits we'll need. */
int digits = uint_decimal_digits(absval);
if (digits <= spec->decimals)
digits = spec->decimals + 1;
/* Actual width, and number of padding characters. */
int width = digits + (!!(spec->flags & INT_FORMAT_POINT))
+ ((value < 0) ? (!(spec->flags & INT_FORMAT_OMIT_MINUS)) : 0)
+ ((value > 0) ? (!!(spec->flags & INT_FORMAT_USE_PLUS)) : 0)
;
int padding = (spec->width > width) ? spec->width - width : 0;
/* Prepad? */
if (padding && (spec->flags & INT_FORMAT_PADDING) == INT_FORMAT_PREPAD) {
while (padding-->0)
writeonly_buffer_set(wo, pos++, spec->padding_char);
}
/* Sign? */
if (value < 0 && !(spec->flags & INT_FORMAT_OMIT_MINUS))
writeonly_buffer_set(wo, pos++, '-');
else
if (value > 0 && (spec->flags & INT_FORMAT_USE_PLUS))
writeonly_buffer_set(wo, pos++, '+');
/* Midpad? */
if (padding && (spec->flags & INT_FORMAT_PADDING) == INT_FORMAT_MIDPAD) {
while (padding-->0)
writeonly_buffer_set(wo, pos++, spec->padding_char);
}
/* Digits and decimal point, if any. */
if ((spec->flags & INT_FORMAT_POINT)) {
pos += digits;
for (int d = 0; d <= digits; d++) {
if (d == spec->decimals) {
writeonly_buffer_set(wo, pos - d, spec->point_char);
} else {
writeonly_buffer_set(wo, pos - d, '0' + (absval % 10));
absval /= 10;
}
}
pos++;
} else {
pos += digits;
for (int d = 1; d <= digits; d++) {
writeonly_buffer_set(wo, pos - d, '0' + (absval % 10));
absval /= 10;
}
}
/* Postpad? */
if (padding && (spec->flags & INT_FORMAT_PADDING) == INT_FORMAT_POSTPAD) {
while (padding-->0)
writeonly_buffer_set(wo, pos++, spec->padding_char);
}
/* Commit. */
writeonly_buffer_commit(wo, pos);
}
#endif /* INT_FORMAT_H */
I'm not "happy" at the format_int() implementation, but it should suffice as an example. (Note how simple the format_string() one defined in writeonly-buffer.h is for comparison. That one is so simple it doesn't take any spec/options as a parameter.)
This integer formatting implementation is based on the right-to-left conversion, repeatedly dividing the integer by ten and using the remainder as the next digit to be set in order of increasing importance.
Note that because the buffer is read-only, we cannot just temporarily save the digits to the beginning of the buffer, then reverse and insert the decimal point afterwards. That's why it uses the call to uint_decimal_digits() to find out how many digits will be needed.
The format_int() function first obtains the current position/length using a call to writeonly_buffer_pos(), then sets characters in a wonky order using writeonly_buffer_set(), and finally sets the new position/length using a call to writeonly_buffer_commit(). This is common to all formatters. Everything else, including how they use the spec or whether they even accept one, is up to each formatter.
(To support formatting-string formatting, I would "register" each formatting function with the associated spec. For the example program, one could use for example "i" for NULL spec, i.e. default signed integer formatting, and "i3.3" for myfix_format. This does require that the conversion specifier has both a start and an end character, and most other languages are using braces; so that's why I used braces too. The example program formatting call would then be format_using(target, "x is {1i} and y is {2i3.3}", &x, &y) for example. The target wouldn't be mybuf, but a stream handle, that would contain a mybuf and a function pointer to output buffers, so that format_using() can do the repeat-formatting do-while loop with any size of stream buffer.)
I don't know why anyone would ever use INT_FORMAT_POSTPAD, but I added that because of symmetry. Also, I consider integer zero signless, so even if you use INT_FORMAT_USE_PLUS, zero won't have a sign. And while the code should implement all the formatting features implied by the flags and the formatting structure, there probably are bugs in it, because again, it's a hot, humid Friday evening, and my brain is in slow mode.
And once again, I curse at not having learned to write better comments from the get go when I first learned to program. It is damned hard to learn to write them well afterwards.