Zinn
- 12 Devlogs
- 40 Total hours
A high-performance, perceptual media-to-ASCII engine written in pure C23.
A high-performance, perceptual media-to-ASCII engine written in pure C23.
4 changed files with 305 additions and 233 deletions
Switching to Meson from a Makefile was quite an easy decision. Considering I want a cross-platform build system that is fast, Meson+Ninja stood out as the best possible candidate. On top of that, Meson easily supports cross-compilation, which fits my use case even more!
The first commit that this devlog details contains the initial implementation of meson.build, and of a Makefile shim.
I decided to write meson.build as idiomatically as possible. I followed the mesonbuild.com documentation for this purpose.
Because of my project’s reliance on ISO C23, I had to make sure that Meson supports setting c23 as an option. That means ensuring Meson is above or equal to v1.4.0.
The compilation flags and linking flags stay the exact same as in my previous Makefile, and on top of that, Meson checks whether a flag is supported and prints it right out while compiling!
The only drawback I encountered while toying with Meson is that the commands can get quite lengthy. For example, to compile and run my project from nothing via meson looks something like this:
meson setup build-debug --buildtype=debug -Db_ndebug=if-release && meson compile -C build-debug && ./build-debug/zinn
While via my previous Makefile it looked like this:
make debug && make run
Of course, this has a lot to do with the fact that Meson saves a bunch of metadata, but nevertheless the flow is slower when trying to remember all the meson options.
That’s why I created a Makefile shim. The term comes from Embedded Artistry, and it refers to a Makefile that acts as a front-end for Meson (in this case).
I wrapped all the meson commands in neat Makefile targets, so I can just run make debug && make run all the same while still getting the valuable Meson features.
With Meson now working in my codebase, it was time for cross-platform support. Adding it was really easy.
The only change in meson.build besides the Windows linker-flag section is that is_windows and is_mingw detection.
Cross-compilation is simply compiling code for a different computer on your own computer. In my case, I wanted to compile for Windows while staying on Linux. In my old Makefile this would be a huge hassle, but Meson only needs this little file to know the target and which cross-compiler binaries to use:
[binaries]
c = 'x86_64-w64-mingw32-gcc'
ar = 'x86_64-w64-mingw32-ar'
strip = 'x86_64-w64-mingw32-strip'
windres = 'x86_64-w64-mingw32-windres'
exe_wrapper = ['wine']
...
The last row is where the magic happens: exe_wrapper tells Meson to run any produced Windows binary through Wine, so I can test the cross-compiled output directly from Linux.
If you checked the attachment out, you can see a bunch of compilation errors; those are there on purpose. I have yet to update my actual code to support Windows.
5 changed files with 1448 additions and 11 deletions
The last core module got its tests. 73 new tests for the CLI argument parser, which makes 341 tests across 7 suites. The parser has more unit tests than any other function in any suite.
To test the parser you need something to parse, so I built a test schema that covers every option type.
The main test schema has four options, each of a different type. Additionally, I made a few deliberately broken schemas for the failure paths: duplicate long names, duplicate short names, an empty schema, one with 33 options (one over the max), and one whose offset_in_config points straight out of the struct.
Writing the tests exposed two issues worth mentioning.
ZINN_EXPECT_DOUBLE_EQ dumped its constexpr double EPSILON into the surrounding scope. As soon as you’d use it twice in one scope, you’d run into redefinition errors. I simply wrapped the macro’s contents in a do {} while(0) loop to prevent this from happening.
The default config values were magic numbers. Now they’re named constants (ZINN_CLI_DEFAULT_WIDTH, …) living in the header, and the tests assert against those instead of hardcoded values.
The zinn_cli_parse tests take up most at 65. The remaining 8 are split between 4 other functions, each with one nullptr path and happy-path.
Every error path of zinn_cli_parse: null schema, null argv, null config, plus each of the broken schemas from the harness, all bail with the correct error code.
This section is the interesting one, because the pre-scan for --help/--version is intentionally naive: it scans for the flag before any real parsing. So zinn --str --help returns OK with help set, even though --str is missing its required value. The tests pin down ordering: --version --help gives version, --help --version gives help, since the loop exits on the first hit. zinn -- --help will toggle the help boolean and skip the rest of the parse.
The last case is a bug. In “./prog – –help” the pre-scan should identify the
--delimiter and exit the pre-scan loop without toggling the help boolean, thus letting it be parsed as a positional argument.
Everything the parser accepts: --width 80, --width=80, -w 80, -w80, and mixed forms in a single invocation. Bundled values handle signs too, so -n-42 parses to -42.
-shello (short for –str hello) also works correctly:)
Positionals fill input_path then output_path; a third one errors out. After -- the parser treats everything as a positional argument.
Fun fact, if you pass
--twice, both get consumed as the “positional mode switcher”.
An int option given abc, overflow past INT64_MAX, 1e309 against a double, values that themselves look like flags (--num --flg), empty values (--str=, --num=), and --str=a=b, where everything after the first equals sign becomes the value.
Both flag variants are asserted to reject unknown identifiers.
Last occurrence wins. Required option checking happens after the full parse.
With the final core module’s unit tests now done, I plan to switch build systems so the engine can work on both Linux and Windows flawlessly, which is a prerequisite for the beautiful media-to-ASCII pipeline.
7 changed files with 782 additions and 32 deletions
The CLI argument parser is Zinn’s final core module. It’s built entirely from scratch, without any external dependencies. Other modules (string views, error handling, logger) were designed to be used seamlessly, and this is the first proof.
The parser is built around declarative option schemas:
const ZinnCliOption ZINN_CLI_OPTIONS[] = {
{.long_name = ZINN_STRING_VIEW_INIT("width"),
.short_name = 'w',
.type = ZINN_CLI_TYPE_INT64,
.offset_in_config = offsetof(ZinnConfig, width),
...},
};
Each option knows its type, whether it’s required, and exactly which byte offset inside ZinnConfig to write to. The parser walks argv, matches tokens against schema entries, and writes parsed values directly into the config struct via offsetof + pointer arithmetic.
This prevents huge bug-prone switch statements you’d have to write (or copy-paste) if using getopt, for example.
The schema validates itself at parse time: duplicate short/long names, offsets exceeding the config struct, zero options, and too many options all get caught before any argv is touched.
For the caller, using this module is rather simple. Declare the schema, call zinn_cli_parse( /*...*/ ), handle failure, and the config is provided nicely in a structure.
The original parsing function I wrote was a huge, monolithic function with over 200 lines. I extracted what I could:
_zinn_validate_schema: validates the schema itself_zinn_pre_scan_help_version: early return for --help / --version before parsing errors can block them_zinn_write_option_value: type-dispatching write via offsetof/switchThe main loop is still ~150 lines. The long-option and short-option matching share too many moving parts: the argv index i (which gets bumped for next-arg values), matched_index (required-flag tracking), the positional_only flag, and the token itself. Extracting them would mean threading half a dozen mutable out-parameters through a helper that’s only called once.
Technically, the parsing function breaks the code style, but it would’ve been too much trouble than its worth to try and extract anything more. (Also, I wrote the rules, so I can do whatever I want)
I own the entire error path. Every parse failure captures file, function, line, and a description in the diagnostic stack. Run --width=abc and you get a trace pointing to the exact switch case that failed, plus a Use ‘–help’ hint (see 2nd attachment). You don’t get that from getopt returning '?'.
After adding a new module, unit tests follow, as usual.
5 changed files with 1402 additions and 2 deletions
After building the string view module, I did what any sane person would do: wrote 110 tests for it. The new “String View” suite brings the total test count to 260 across 6 suites, and the assertion count to over 1000!!!
If we exclude comments and blank lines, that’s still a lot of lines. The answer is simple: the editor I use is Neovim, which allows me to use find & replace (see first attachment), quickly copy, delete, manoeuvre lines with simple keybinds, and much more!
In the attachment you can see me writing the general structure of roughly 20 tests in one single command, thus saving me 10s of minutes. I simply then replace the test-specific data and assertions and I’m all set!
ZINN_EXPECT_DOUBLE_EQ
Floating point equality is a lie. ZINN_EXPECT_EQ converts to uintptr_t under the hood. It being an integer type, any floating point number you convert to uintptr_t gets its floating part cut off. So I added ZINN_EXPECT_DOUBLE_EQ for checking equality of floating point numbers.
Two local macros keep the test file from being even longer than it already is:
EXPECT_ZINN_STRING_VIEW_NULLPTR(sv)EXPECT_ZINN_STRING_VIEW_VALID(sv, e_data, e_len)Each macro expands to a few lines of code, which might seem pointless at first, but for 110 tests that use the macro, it adds up.
Every public function in the string view module was tested thoroughly: null pointers, empty inputs, boundary values, and at least one “happy” path.
Both constructor macros are verified. from_parts rejects null data and zero length. from_cstring rejects null and empty C strings.
subview is tested with the offset past the end, offset equal to length, zero count, clamping when count overshoots, exact fits, and partial slices. Each trim variant has tests for null input, no whitespace, some whitespace, all whitespace, and mixed content with whitespace only on one side.
equals checks length mismatch, null data on either side, the same-pointer fast path, exact match, and content mismatch.
equals_case_insensitive goes further: same categories plus non-ASCII bytes (the lookup table should pass them through unchanged) and a mismatch on the last character to make sure the loop doesn’t stop early.
starts_with and ends_with both test null data, null prefix/suffix, empty prefix/suffix (should return true), empty source (should return false), prefix/suffix longer than the source, match, and no match.
find_char tests null data, null out pointer, empty view, char not found, and found at positions first, middle, and last.
split_first has the most edge cases: null output pointers independently, null string view, empty string view, delimiter not found, delimiter at start, middle, end, and a single character that is the delimiter.
to_int64 and to_double are the heaviest. Both test null out, null data, whitespace only, trimming working, buffer overflow, overflow/underflow via ERANGE, no digits, and partial parses like "123abc".
to_int64 additionally tests positive, negative, zero, INT64_MIN, INT64_MAX, plus sign prefix, and a lone hyphen.
to_double additionally tests basic, scientific notation, negative, plus prefix, integer-only input, nan, inf, -inf, decimal-only like ".5", and a bare dot.
Yet another core module. Regardless of what it is, I’m still hours away from coding some fun ASCII conversions..
3 changed files with 624 additions and 4 deletions
The next core module I built was the string view.
The primary goal of this module was to build it fast, avoid heap allocation, ambiguity, and make it “bulletproof”.
My reasoning behind making this module is simple: it will be heavily used in the upcoming CLI argument parser and the media-to-ascii engine.
ZinnStringView is dead simple, just a const char* data and a size_t length. Two constructors build views from either explicit parts or null-terminated C strings. Both return ZINN_STRING_VIEW_NULLPTR if you pass them null or empty data.
The coolest part of this module is ZINN_STRING_VIEW_LITERAL(). It wraps a string literal at compile time using _Generic to detect whether you passed an actual literal or a pointer variable.
ZinnStringView sv1 = ZINN_STRING_VIEW_LITERAL("hello"); // works
ZinnStringView sv2 = ZINN_STRING_VIEW_LITERAL(pointer); // compile error (see attachment)
If you pass a variable, _Generic sees char* or const char* and the static_assert fires with a message telling you to use zinn_string_view_from_cstring() instead.
Also, while making the attachment I realized what I wrote is actually not correct for C23, as you cannot have static assertions in expressions. This is only a feature in the upcoming C2Y (C29) standard.
Generally, I preferred using standard library functions as they are battle-tested, and more often than not use SIMD instructions for much faster execution than whatever I could come up with.
zinn_string_view_subview gives you a sub-slice with bounds clamping. If offset is past the end you get ZINN_STRING_VIEW_NULLPTR. If offset + count overshoots, it clamps down. The three trim functions share one implementation behind an enum that controls whether to trim left, right, or both.
I built a 256-byte lookup table that maps A-Z to a-z for case-insensitive comparison. It’s locale-independent, so no tolower() surprises on Turkish systems. find_char delegates to memchr, which your libc likely implements with SIMD (16-32 bytes per cycle). starts_with and ends_with are just memcmp with an offset. split_first finds the delimiter via memchr and chops the view in two.
Parsing from a string view is tedious because standard library functions expect null-terminated strings. The solution is to copy into a small stack buffer, null-terminate it, and call the respective function. The double variant uses strtod_l with a POSIX “C” locale so "3.14" is parsed correctly even if the user’s system uses commas as decimal separators. Both trim whitespace first, check for overflow via errno, and reject partial parses like "123abc".
My favorite activity, unit tests!
5 changed files with 1312 additions and 0 deletions
After implementing the error code system, diagnostic stack, and scope guard, I needed to make sure they actually work before I start building on top of them. So, my favorite activity, writing unit tests, was awaiting me with 64 more tests across 3 new suites.
ZINN_EXPECT_EQ_STR
The diagnostic stack tests compare message strings. ZINN_EXPECT_EQ would compare pointer addresses if you passed strings, not contents, so I added a strcmp based variant:
ZINN_EXPECT_EQ_STR(frame->message, "42 hi");
On failure it prints actual vs expected strings, which makes debugging test failures a lot easier.
The tests verify that each field lands in the correct position and that overflows are safely masked out:
ZINN_DOMAIN_LOGGER | ZINN_SEVERITY_INFO | 0x42, then extract each field and verify0xFFFFF, 0xFF, 0xF) clip overflowed fields back to zero, tested with values one past each bit boundary"unknown", null function maps to "unknown", null format maps to "(none)", empty format produces empty string, printf args ("%d %s", 42, "hi") produce "42 hi", truncation at exactly 127 bytes + null terminator at 128false, depth stays pinned at 16, the last valid frame is untouchednullptr, non-empty returns the top frame (LIFO: second push overwrites the peek target), after clear returns nullptr
Each macro has its own test helper and multiple assertions:
ZINN_DIAGNOSTICS_PUSH: verifies __FILE__, __func__, __LINE__ are injected, with and without format argsZINN_RETURN_IF_ERR: passing ZINN_OK skips the frame and returns OK; passing an error pushes a frame containing "Propagated error" and returns the errorZINN_RETURN_IF: condition true: pushes "Condition failed (true)" and returns the error; false: no frame, returns OK.ZINN_TRY: wraps a successful call (forwards the value), wraps a failing call (pushes "TRY macro captured failure", returns the error)ZINN_CHECK_ERROR: the only macro that doesn’t return. After pushing a frame, execution continues. Verified with a continued flag, format args, and a static messageTwo isolation tests: one interleaves push/pop/push and verifies frame independence (frame 0 still holds "0" after pops, frame 1 holds "new"). The other pushes two frames with different error codes and verifies LIFO ordering and the peek return value at each step.
Scope guards fire implicitly when a scope exits. Tested with global counters for basic fire, multiple guards, early return, null-safety, nested scopes, named guards, disarm, double-disarm, and direct execute() calls with null/disarmed/null-cleanup guards.
With 160 tests across 5 suites, I can take a short break from writing unit tests. Next up is the CLI argument parser (probably) the final core module before I can get to the fun ASCII stuff.
18 changed files with 1153 additions and 547 deletions
I wasn’t happy with the old error handling. A flat enum doesn’t tell you what went wrong or how bad it is. So I tore it out and replaced it. I’m proud of this module because it uses modern C23 features everywhere you look, is fast, secure, and gives me the important debugging info in case something goes wrong.
This commit contains the initial implementation of the module, although with a few bugs I addressed in one of the two later commits.
Warning! This commit DOES NOT compile. I haven’t refactored the codebase to use the new error handling implemented in this commit.
The new zinn_err_t is a uint32_t with three fields packed into it:
[8 bits domain | 4 bits severity | 20 bits code]
A thread_local fixed-depth LIFO stack (max 16 frames, 128 message chars each). Every frame records file, function, line, the packed error code, a human-readable message, and a unix timestamp.
Pushing a frame manually is tedious, so I wrote macros that inject them for you.
Higher-level macros build on this:
ZINN_RETURN_IF(condition, error): if true, push a frame and return the errorZINN_RETURN_IF_ERR(expression): if the expression fails, push and returnZINN_TRY(expression): same, but evaluates to the expression’s value on successZINN_CHECK_ERROR(call, message, ...): push a frame if error, but keep goingzinn_diagnostics_dump_frames() dumps the whole stack to stderr with colored formatting, newest first.
Check the attachment to see the stack dump in a terminal. Contains all the info you’d ever need.
C doesn’t have RAII. But GCC has __attribute__((cleanup)). The scope guard module wraps it:
ZINN_DEFER_CLEANUP(function, pointer)
This declares a local guard that calls function(pointer) when the scope exits, whether by normal return or early return. You can disarm it with ZINN_SCOPE_GUARD_DISARM(name) if you transfer ownership.
The logger now uses this for mutex cleanup.
The old code scattered manual pthread_mutex_unlock() calls across every return path. One missed unlock would deadlock the whole process.
The codebase was refactored to use the new error handling and diagnostic functions, macros and types.
A few examples:
zinn_err_t everywhere; precondition checks use ZINN_RETURN_IF; mutex handling uses scope guardszinn_arena_create changed from returning a pointer to returning an error code with an output parameter. Every error site now pushes a diagnostic frame with detailsfclose() calls that leaked file descriptors and a renamed internal function amongst a few othersThe test count also dropped from 50 to 49 Logger tests because I removed a redundant test.
With the error handler now functional, it will be finally time for the (probably) final core mod-… oh wait. Nevermind that. Next I have to write a ton of unit tests for this new module. Lucky me!
8 changed files with 1638 additions and 44 deletions
This devlog covers another core module, the Arena Allocator, as well as improvements to the test framework. Implementing this took quite some time, due to the fact that I want to make sure the codebase stays secure and fast.
The next core module I implemented is a linear arena allocator, the polar opposite of a general-purpose malloc. The whole purpose is simple: no free individual allocations. Just bump a pointer and reset the whole thing when you’re done. It’s also quite fast.
Every pointer returned by the arena is aligned to max_align_t (typically 16 bytes on x86-64).
When the CPU loads an 8-byte value from an aligned address, the memory controller fetches a single 64-byte cache line in one transaction. The bytes land in the CPU’s load buffer in order, and execution continues the next cycle.
When the same load is unaligned (say, address 0x1003 instead of 0x1000), the 8 bytes straddle two cache lines: 0x1000 and 0x1040. The memory controller now issues two separate cache line fills, the load buffer stitches the result together from the two halves, and the pipeline stalls waiting for the second request to arrive. On modern x86-64 this penalty is roughly 2-3x the latency of an aligned load.
For an allocator focused on speed, every allocation must be aligned. The arena guarantees this in two branchless instructions:
uintptr_t raw = (uintptr_t)arena->memory + arena->offset;
uintptr_t aligned = (raw + 15) & ~15;
The snapshot API (zinn_arena_snapshot_begin / zinn_arena_snapshot_end) lets you checkpoint the arena offset and roll back to it later. In debug builds the rolled-back region is poisoned with 0xAA so dangling reads are immediately obvious.
47 thorough tests covering just about everything about the arena.
Every test function’s runtime is now measured. The runtime is printed next to the PASS/FAIL label with microsecond precision.
Check out the second attachment.
This commit also fixes a subtle C23 compatibility issue: nullptr has type nullptr_t under C23, which can’t be directly cast to uintptr_t. I added a _Generic dispatching macro (ZINN_TO_PTR) that maps nullptr_t to (void*)0 while leaving everything else untouched.
The failure messages also got an upgrade: actual and expected values are now printed numerically instead of a bare expression string.
The original test runner supported one suite at a time. You’d call zinn_test_summary() and get a single block. Now the architecture is multi-suite.
The final output is a detailed breakdown:
Check out the first attachment if you are curious how it looks.
With memory management settled and testing infrastructure solid, the next milestone is an error handler.
8 changed files with 1264 additions and 30 deletions
The next priority wasn’t to build more features, but to ensure that the ones I have are unbreakable. Thus, I’ve implemented a custom, zero-dependency Unit Testing Framework and a comprehensive suite of 50 tests for the Logger module.
Because of my ego in the way, I didn’t want to rely on heavy external libraries like GoogleTest or Check (or any external library, for that matter).
The result?
_zinn_log_reset_internal) to Logger that allows the test suite to scrub the global state of the Logger between runs. This ensures that every test starts with a clean state, which prevents state-leakage bugs.EXPECT and ASSERT checks that capture the exact file and line number of any failure.I also updated the Makefile to add a
testtarget, which compiles all source files EXCLUDINGmain.c(as I have a customtest_main.c), test files, and runs the tests.
Instead of being lazy, I decided to write a test for everything imaginable for the Logger. This may seem pointless, even redundant at times, but as I say, better be safe than sorry.
Proving that Init/Shutdown/Reset cycles don’t leak memory or leave the mutex in a deadlocked state. I verified that the Logger returns to a “Zero State” after shutdown, allowing it to be safely re-initialized later.
This was the most critical part. I wrote a “ruler” test to verify exactly what happens when the 1024-character message buffer is exceeded. By analyzing the memory buffer line-by-line, I proved that the logic correctly truncates and appends ... at the exact 1023rd byte without overflowing the stack.
Every public function was tested with nullptr arguments.
Where applicable, of course.
Using fmemopen() to capture the logger’s output in memory, I verified the exact formatting of timestamps, ANSI colors, and source-location metadata.
Ajajaj, yet another POSIX-only function. Surely this won’t backfire…
Now that the Test Suite is done, and Logger unit tests are written, it’s finally time to tackle yet another core component, this time it being a custom (minimalist) memory allocator.
7 changed files with 89 additions and 16 deletions
This is a very short devlog, as it was a very short integration. I simply added a Doxyfile, which controls how the documentation will be generated. I set it according to my project, e.g. a C project, the project name, what directories it should traverse etc.
To make the documentation generation and opening as seamless as possible, I added a docs target to the Makefile, which checks if you have doxygen installed. If not, it throws a simple error message and doesn’t proceed. Once it’s verified that doxygen is available, it generates the documentation and opens it in your default browser via xdg-open.
While I was going through the codebase for a final check of the comments, I noticed a few functions didn’t have any Doxygen comments whatsoever (especially the static ones) and a few things were incorrectly documented. I added and fixed comments for clean doxygen documentation.
In the attachment you can see the generated HTML documentation for a few functions located in logger.c. Notice the @note being displayed neatly with a yellow background on the website.
6 changed files with 594 additions and 9 deletions
The first core module I implemented is the Logger. I feel like I spent a huge amount of time on this, but it was well worth it, as it’s a marvel. Below you can read about the engineering of the logger.
This is a quick overview, for the full context you can check out the source code.
The logger uses a two-tier filtering system.
__VA_OPT__, the logger identifies the build type. In a release build, TRACE and DEBUG calls are completely stripped from the binary. They don’t just “not print”; they don’t exist in the final machine code.While zinn doesn’t use multithreading yet, I felt like it’s best to “future-proof” the Logger by guarding everything with pthread. This ensures, that whenever (or if at all) I add multithreading, the stream output will be clean and not a garbled mess of characters.
Again, this isn’t used currently, so this is another “future-proofing” measure. The logger can seamlessly start writing into any stream desired, as long as it exists. This will be used in the future, but no spoilers as to what will use it ;)
timespec_get and localtime_r for nanosecond-precision timestamps with thread-safe formatting.[[gnu::format(printf, x, y)]], allowing the compiler to catch type-mismatches in log messages at build-time.malloc. All formatting happens in a fixed 1024-byte stack buffer. This also prevents nasty, difficult-to-catch memory bugs, which could be fatal in a core module such as the logger.This screenshot perfectly demonstrates the preprocessor filtering. Notice the difference between the release and debug runs. In release, the TRACE log is vanished. In debug, the logger is fully verbose. This confirms that the compile-time filtering is working.
Showcase of multiple severities, as well as the FATAL one. Notice the Return code: 2 at the bottom. This proves that the logger successfully captured the error, flushed the stream, and triggered a clean abort() to prevent further corruption.
Ignore the makefile error, I suspect it occurs when I run
make run, and the binary exits abnormally (e.g. return code != 0), it thinks it’s an error.
Compared the the previous screenshot (right above), you can see the TRACE log level appears. This didn’t show in the previous screenshot as the minimum log level was higher than TRACE, thus showing the minimum log level works.
Here you can see how the code looked for the previous two screenshots. In the init function (zinn_log_init), you pass the minimum log level as the parameter, so when it was ZINN_LOG_TRACE, it logged all the severities. When it was something higher, it didn’t log TRACE, for example.
If you were to take a look at the code on the repository, you might notice that it is a bit different. That is due to this screenshot being a bit old, and I’ve went through like 2 hours of improving the Logger:)
With the first core module done, the only correct next step is to implement another core feature.
10 changed files with 1056 additions and 0 deletions
For zinn, I decided that the foundation had to be as robust as the final output. Before writing a single line of code, I spent the first hour or so building a thorough foundation from scratch.
For this project I have decided to use the latest C23 standard. Why? The answer is simple: modern features.
nullptr: Replaces the macro NULL. It’s a real type, which prevents type-confusion bugs and makes the code’s intent explicit.constexpr: This allows us to define constants with full type safety. More importantly, its value is known at compile-time, thus the compiler can catch math errors or type mismatches before the code even runs.And many others…
I have outlined the code style that will be followed in this codebase. The whole file can be found here.
The directory structure is simple:
.
├── bin <-- contains executable binaries
├── build <-- contains intermediate files
├── include <-- contains header files, allows sub-dirs
└── src <-- contains source files, allows sub-dirs
I have developed a recursive Makefile that supports multiple build confiruations (Debug, Release, Sanitize, Coverage). It features:
clang-tidy, cppcheck and GCC’s -fanalyzer.-MMD -MP flags to automatically generate header dependencies. This means the build system is “smart”; it only recompiles what is absolutely necessary.|) to handle folder creation. This prevents “ghost recompilation” where the binary is rebuilt just because a folder’s timestamp changed.The project is licensed under GPLv3, which ensures it stays as FOSS (Free and Open Source Software) forever.
Next, I plan to implement bulletproof core components, such as a logger and CLI argument parser.