Skip to content
yuygfggPublic

About

Intuitive, type-safe {fmt} formatting for C11, built with custom types in mind.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

cfmtx

Intuitive, type-safe {fmt} formatting for C11, built with custom types in mind.

cfmtx brings modern {fmt} syntax, positional arguments, compile-time type dispatch, and user-defined type formatting to C11. It is specifically designed to make formatting user-defined C structures effortless.

// No more manual field extraction
// Format custom structs and built-in types seamlessly with {fmt} syntax
cfmtx_print(stdout, "user={:>10}, point={}, hex={:#x}\n", "alice", point, 255u);

Highlights

  • Modern {fmt} Syntax in C11: Positional arguments, custom formatting specs, alignment, padding, and dynamic width/precision.
  • Type-Safe Dispatch: Built on C11 _Generic, eliminating format specifier mismatches.
  • First-Class Custom Types: Format C structures directly without manual string building.
  • Explicit Output Ownership: Write to fixed buffers and streams without allocation, or create an owned string in one formatting pass.
  • Clean C ABI: Application source files compile with standard C11 without needing a C++ compiler.

Contents


Quick Start

Built-in Types

No registration or setup is needed for standard C types. Pass values directly:

#include <cfmtx/cfmtx.h>
#include <stdio.h>

int main(void) {
  // Format directly to stdout
  cfmtx_print(stdout, "value={:>10.2f}, hex={:#x}\n", 3.14159, 255u);

  // Or format safely into a null-terminated buffer
  char buffer[64];
  cfmtx_format_z(buffer, sizeof(buffer), "value={:>10.2f}, hex={:#x}", 3.14159, 255u);
  printf("Buffer: %s\n", buffer);

  return 0;
}

Output:

value=      3.14, hex=0xff
Buffer: value=      3.14, hex=0xff

Tip

Need a C string? Use cfmtx_format_z for a caller-owned fixed buffer. Use cfmtx_format_alloc when the required size is not known.


Custom Types

To format a custom C struct, define a formatter with CFMTX_FORMATTER and register it using the #define / #include <cfmtx/register.h> pair:

#include <cfmtx/cfmtx.h>
#include <stdio.h>

typedef struct Vec2 {
  double x;
  double y;
} Vec2;

// 1. Define the formatter
static CFMTX_FORMATTER(format_vec2, Vec2) {
  // spec contains custom format specifiers (e.g. {:compact}); empty for "{}"
  (void)spec;
  return cfmtx_writer_format(writer, "({}, {})", value->x, value->y);
}

// 2. Register the (Type, Formatter) pair
#define CFMTX_REGISTER (Vec2, format_vec2)
#include <cfmtx/register.h> /* Consumes CFMTX_REGISTER and expands registration */

int main(void) {
  Vec2 point = {1.5, 2.5};
  return cfmtx_print(stdout, "point is {}\n", point) == CFMTX_OK ? 0 : 1;
}

Output:

point is (1.5, 2.5)

Note

Why #include <cfmtx/register.h>? Standard C cannot generate file-scope type dispatch declarations from a single macro call. cfmtx uses this define-then-include pattern to safely configure _Generic table entries for your type at compile time. In a multi-file project, put this pair in the type definition header. Consumers then only include that header.

Complete source: examples/vec2.c.


Choosing an API

Task Recommended Function Input Format Output Destination Success Return Value
Create an owned C string cfmtx_format_alloc const char* format, varargs Newly allocated null-terminated string Total characters (excluding \0)
Format to C string cfmtx_format_z const char* format, varargs Null-terminated char[] Total characters (excluding \0)
Print to stream cfmtx_print const char* format, varargs FILE* stream CFMTX_OK (0)
Format raw bytes cfmtx_format const char* format, varargs Raw byte buffer Total byte count
Allocate with pre-packed args cfmtx_vformat_alloc cfmtx_string_view, cfmtx_arg[] Newly allocated null-terminated string Total characters (excluding \0)
Format with pre-packed args cfmtx_vformat_z cfmtx_string_view, cfmtx_arg[] Null-terminated char[] Total characters (excluding \0)
Print with pre-packed args cfmtx_vprint cfmtx_string_view, cfmtx_arg[] FILE* stream CFMTX_OK (0)
Append in custom formatter cfmtx_writer_format const char* format, varargs cfmtx_writer* CFMTX_OK (0)

Important

The v prefix represents pre-packed argument arrays (cfmtx_arg[]), NOT va_list. Unlike vprintf, cfmtx_v* functions accept an array of cfmtx_arg structures. This enables building reusable argument packs once and formatting them across multiple targets without re-evaluating expressions.


Allocated String Output

Use cfmtx_format_alloc when the output size is not known. The function grows the output while it formats. It does not run the formatter once to measure and again to write.

char* text = NULL;
int length = cfmtx_format_alloc(&text, "name={}, score={}", name, score);
if (length < 0) {
  // text is NULL. Handle the formatting or allocation error.
  return length;
}

use_text(text, (size_t)length);
cfmtx_free(text);

The result always has a final null character. The returned length does not include that character. Release the result with cfmtx_free, including when cfmtx is a shared library.


Buffer Output

Use cfmtx_format_z to format into fixed-size character arrays:

char buffer[64];
int result = cfmtx_format_z(buffer, sizeof(buffer), "answer={}", 42);

if (result >= 0 && (size_t)result >= sizeof(buffer)) {
  // Output was truncated, but buffer is still safely null-terminated!
}
  • Capacity & Null Termination: size is the total buffer capacity including space for '\0'. When size > 0, output is capped at size - 1 bytes and always appends '\0'.
  • Truncation Handling: Truncation is not an error. The function returns the total formatted length required, like snprintf.
  • Size Query: Pass cfmtx_format_z(NULL, 0, ...) to compute the required length. Use this only when an external allocator or an existing buffer requires the size first. A later formatting call formats the value again. Use cfmtx_format_alloc for an ordinary heap string.

Stream Output

Use cfmtx_print to write directly to stdout, stderr, or any FILE*:

if (cfmtx_print(stderr, "Error: code={:#x}, message={}\n", 0x404, "Not Found") != CFMTX_OK) {
  // Handle I/O error
}

Format String Syntax

cfmtx supports the standard {fmt} format specification mini-language:

char buffer[64];
cfmtx_format_z(buffer, sizeof(buffer), "value={:>10.2f}, hex={:#x}", 3.14159, 255u);
// Result: "value=      3.14, hex=0xff"

Supported features for built-in types:

  • Positional arguments: "{0} {1} {0}"
  • Alignment & fill: "{:<10}" (left), "{:>10}" (right), "{:^10}" (center), "{:*^10}" (custom fill)
  • Sign & radix: "{:+}", "{:#x}", "{:#b}", "{:#o}"
  • Width & precision: "{:10.4f}"
  • Dynamic width/precision: "{:{}.{}f}" with runtime arguments

Custom Type Registration

How Registration Works

Each registration consists of an adjacent #define and #include <cfmtx/register.h> pair at file scope:

#define CFMTX_REGISTER (TypeName, formatter_function)
#include <cfmtx/register.h>

The header consumes CFMTX_REGISTER and undefines it automatically. For a shared type, put the pair in the header that defines the type. Keep it inside that header's include guard.


Best Practices: Multi-File Projects

Registration changes the _Generic dispatch table in the current translation unit (TU). A type header applies that registration once in every C TU that includes the type.

1. Header file (vec2.h)

Define the type, declare its formatter, and register the pair:

#ifndef VEC2_H_
#define VEC2_H_

#include <cfmtx/cfmtx.h>

typedef struct Vec2 {
  double x;
  double y;
} Vec2;

#ifdef __cplusplus
extern "C" {
#endif

CFMTX_FORMATTER(format_vec2, Vec2);

#ifdef __cplusplus
}
#else

// Make Vec2 format-ready in each C translation unit that includes this header
#define CFMTX_REGISTER (Vec2, format_vec2)
#include <cfmtx/register.h>
#endif

#endif

2. Implementation file (vec2.c)

Implement the formatter:

#include "vec2.h"

CFMTX_FORMATTER(format_vec2, Vec2) {
  (void)spec;
  return cfmtx_writer_format(writer, "({}, {})", value->x, value->y);
}

3. Consumer file (main.c)

Include the type header and use the type. Do not add registration code.

#include "vec2.h"
#include <stdio.h>

int main(void) {
  Vec2 v = {3.0, 4.0};
  cfmtx_print(stdout, "vector: {}\n", v);
}

The formatter has external linkage because dispatch helpers in multiple TUs call it. Registration creates only TU-local helpers.


Formatter Contract & Writer API

The CFMTX_FORMATTER(name, Type) macro expands to:

int name(cfmtx_writer *writer, cfmtx_string_view spec, const Type *value);
Parameter Description
writer Output sink for the current formatting operation. Valid only during the call.
spec Custom format specifier view (the text between : and }). Non-null-terminated.
value Pointer to the object being formatted.

Inside the formatter, use the writer functions:

  • cfmtx_writer_write(writer, string_view): Append an unformatted string view.
  • cfmtx_writer_format(writer, "...", ...): Format expressions and append output.
  • cfmtx_writer_vformat(writer, format_view, args, num_args): Append from pre-packed arguments.

Tip

Always return the result of cfmtx_writer_* directly (or CFMTX_OK on success). If you encounter a validation error in your custom spec, return a negative integer status.


Nested Custom Types

A custom formatter can format other registered types seamlessly.

typedef struct User {
  uint64_t id;
  cfmtx_string_view name;
} User;

typedef struct LogEvent {
  User actor;
  cfmtx_string_view message;
} LogEvent;

// Register User first...
#define CFMTX_REGISTER (User, format_user)
#include <cfmtx/register.h>

// Then LogEvent's formatter can format User with "{}"
static CFMTX_FORMATTER(format_log_event, LogEvent) {
  return cfmtx_writer_format(writer, "[User: {}] msg: {}", value->actor, value->message);
}

#define CFMTX_REGISTER (LogEvent, format_log_event)
#include <cfmtx/register.h>

Complete source: examples/log-event.c.


Prepared Argument Arrays & Lifetimes

When formatting the same set of arguments repeatedly (e.g., logging to both a file and a memory buffer), you can pre-pack arguments into a cfmtx_arg array.

Vec2 point = {1.5, 2.5};

// Pack arguments once
cfmtx_arg args[] = {
    CFMTX_MAKE_ARG("point"),
    CFMTX_MAKE_ARG(point),  // Shallow copy
    CFMTX_REF(point),       // Reference by pointer
};
size_t num_args = sizeof(args) / sizeof(args[0]);

// Format to an owned string
char* text = NULL;
int length = cfmtx_vformat_alloc(
    &text, CFMTX_STRING_VIEW_LITERAL("{}: copied={}, ref={}"), args, num_args);
if (length >= 0) {
  consume(text, (size_t)length);
  cfmtx_free(text);
}

// Or format to a fixed buffer
char buffer[128];
cfmtx_vformat_z(buffer, sizeof(buffer), CFMTX_STRING_VIEW_LITERAL("{}: copied={}, ref={}"), args, num_args);

// Format to stream
cfmtx_vprint(stdout, CFMTX_STRING_VIEW_LITERAL("{}: copied={}, ref={}\n"), args, num_args);

Complete source: examples/vformat.c.


Copy vs Reference (CFMTX_MAKE_ARG vs CFMTX_REF)

Macro Storage Strategy Lifetime Best For
CFMTX_MAKE_ARG(value) Shallow copy in temporary storage Valid until the enclosing scope exits Small structs, scalar values, and temporary values
CFMTX_REF(value) Stores pointer to live lvalue Caller must keep original object alive Large structs or objects whose live values should be reflected

Lifetime Gotchas

Warning

Do not use CFMTX_MAKE_ARG inside loops to populate an external array! CFMTX_MAKE_ARG allocates temporary stack storage at its lexical call site. In a loop, every iteration reuses the same storage slot, causing all array entries to point to the last element:

cfmtx_arg args[2];
for (size_t i = 0; i < 2; ++i) {
  args[i] = CFMTX_MAKE_ARG(points[i]); // ❌ WRONG: Storage is reused!
}

Correct Solutions:

  1. Use CFMTX_REF(points[i]) if points remains alive:
    for (size_t i = 0; i < 2; ++i) {
      args[i] = CFMTX_REF(points[i]); // ✅ OK: References the live array element
    }
  2. Format synchronously inside the loop:
    for (size_t i = 0; i < 2; ++i) {
      cfmtx_print(stdout, "{}\n", points[i]); // ✅ OK
    }

Common Gotchas & Pitfalls

Because cfmtx relies on C11 _Generic compile-time type matching, keep these standard C behaviors in mind:

C Expression C Type Recommended Syntax Why
'x' int (char)'x' Character constants have type int in C; without a cast, it formats as an integer (e.g. 120).
true / false int / bool (bool)true In standard C11 <stdbool.h>, true may expand to 1 (int).
Bit-fields Bit-field (int)flags.bits Bit-fields cannot have their address taken or be directly matched in _Generic.
Compound Literals Struct ((Vec2){1.0, 2.0}) Wrap in extra parentheses so the comma inside {1.0, 2.0} is not treated as a macro argument separator.
Object Pointers MyType* (const void*)ptr Arbitrary typed pointers must be cast to void* or const void* to format addresses ({:p}).

Error Handling

cfmtx uses clear, consistent return values:

  • String functions (cfmtx_format_alloc, cfmtx_vformat_alloc, cfmtx_format_z, cfmtx_vformat_z): Return non-negative formatted length on success; negative error code on failure.
  • Stream & Writer functions (cfmtx_print, cfmtx_writer_*): Return CFMTX_OK (0) on success; negative error code on failure.
int result = cfmtx_format_z(buffer, sizeof(buffer), "value={}", value);
if (result < 0) {
  fprintf(stderr, "Formatting failed with error code: %d\n", result);
}

Status Codes

Constant Value Description
CFMTX_OK 0 Success (for stream and writer operations).
CFMTX_ERROR -1 General error (invalid format syntax, allocation failure, I/O error, or output exceeding INT_MAX).
CFMTX_ERROR_INVALID_ARGUMENT -2 Null pointer, invalid argument tag, or argument count exceeded.
CFMTX_ERROR_CUSTOM -3 A custom formatter returned an invalid positive code.
Negative values < -3 < -3 Application-defined error codes returned by custom formatters.

Technical Specifications & Limits

Requirements

  • Consumer code: C11 compliant compiler with _Generic and compound literals (GCC 4.9+, Clang 3.8+, MSVC 2019+ with conforming preprocessor /Zc:preprocessor).
  • Library build: C++11 compiler and {fmt} 12+.

Supported Types

  • Integers: char, signed char, unsigned char, short, unsigned short, int, unsigned int, long, unsigned long, long long, unsigned long long
  • Floats: float, double, long double
  • Booleans: bool
  • Strings: char*, const char*, cfmtx_string_view (supports embedded nulls)
  • Pointers: void*, const void*
  • Custom Types: Types registered via register.h

Constraints & Limits

Constraint Limit Behavior When Exceeded
Arguments per formatting call 16 Compile-time macro error or CFMTX_ERROR_INVALID_ARGUMENT
Registered custom types 32 per translation unit Preprocessor compile error on 33rd registration
Maximum formatted output size INT_MAX bytes Returns CFMTX_ERROR
Nested replacement fields in custom spec Not supported Returns CFMTX_ERROR

Thread Safety

cfmtx contains zero mutable global state. All formatting operations are fully reentrant and thread-safe as long as distinct threads do not write to the same buffer/FILE* or mutate objects passed via CFMTX_REF.


Build and Install

Building with CMake

# Configure and build
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build

# Run tests
ctest --test-dir build --output-on-failure

# Install
cmake --install build --prefix /usr/local

Running Examples

./build/cfmtx-vec2
./build/cfmtx-vformat
./build/cfmtx-log-event

Integrating into Your Project

In your project's CMakeLists.txt:

cmake_minimum_required(VERSION 3.20)
project(my_project C)

find_package(cfmtx CONFIG REQUIRED)

add_executable(my_app main.c)
target_compile_features(my_app PRIVATE c_std_11)
target_link_libraries(my_app PRIVATE cfmtx::cfmtx)

License

This project is licensed under the Mozilla Public License 2.0.

About

Intuitive, type-safe {fmt} formatting for C11, built with custom types in mind.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages