From 0f3683629a14899cb5428a68b79f349d470c0991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 9 Aug 2026 14:59:10 +0200 Subject: [PATCH 1/5] feat: modernize template to C23 and fix library bugs Move the whole template to -std=c23 (from the -std=c2x draft spelling) and rework the build, editor, and CI wiring around it. Modeled on the codecamp project's Makefile: per-object header dependency tracking, a single build/ tree, Doxygen docs, and heavily commented rules. Library correctness - list_append linked a node to itself when the list was empty, making a one-element list circular; list_destroy then looped forever. Only masked because the old suite always appended ten at a time. - list_prepend_value/list_append_value malloc'd a buffer and immediately overwrote the pointer with the caller's, leaking the buffer and aliasing caller-owned memory. They copy the bytes now, so the caller keeps ownership. - list_pop left tail dangling at the freed node after popping the last element, so the next append wrote through a stale pointer. - Added the missing malloc failure checks; list_destroy and list_node_destroy now tolerate nullptr like free does. - linkedlist.h included to reach size_t; it uses now. C23 - nullptr, bool/true/false as keywords, static_assert and typeof without any supporting header, constexpr, and (void) prototypes. - [[nodiscard]] on every non-void function, [[noreturn]] on the test harness bail-out, [[reproducible]] on list_size/list_is_empty. - [[reproducible]]/[[unsequenced]] are guarded behind __has_c_attribute: Clang has neither, and clangd would otherwise flag every declaration. The guard tests for non-zero rather than >= 202311L because Clang reports the C++ paper dates for the other five attributes. - Header #errors out on __STDC_VERSION__ < 202311L instead of emitting a wall of syntax errors on nullptr. Build - Static and shared libraries from one set of -fPIC objects, with a correct soname/install_name and install/uninstall honouring PREFIX and DESTDIR. - Compiler auto-detection (newest Homebrew gcc-NN on macOS, gcc on Linux), overridable with CC= because $(origin CC) is tested rather than ?=. - make sanitize builds into build/sanitize/ so ASan and plain objects never mix; make memcheck runs leaks or valgrind. - Dropped the order-only directory prerequisites: $(BUILD) expands to "build", which is also a phony target, so make merged the two and every rule ordered after | $(BUILD) silently gained "static shared" as prerequisites. - Warning set now includes -Wconversion -Wshadow -Wcast-qual -Wstrict- prototypes -Wvla; the tree is clean under both gcc-16 and clang. Tests - Replaced bare assert with a CHECK harness. assert(list_append(...)) would have compiled the call away entirely under -DNDEBUG. - Regression tests for all three bugs above, plus struct payloads, zero-size elements, null arguments, and ownership transfer. Editor and CI - clangd drives IntelliSense; the cpptools engine and its Tag Parser fallback are off because they do not parse C23 attributes in C. cpptools stays for the debugger. compile_flags.txt gives clangd the same -std=c23. - launch.json version corrected to 0.2.0, with lldb, gdb, and sanitizer configurations; tasks for every Makefile target; extensions.json added. - CI matrix over Ubuntu gcc-14 and clang-18 plus macOS Apple Clang, which is what exercises the __has_c_attribute fallback. Separate format, valgrind, and Doxygen jobs. Docs - docs/C23.md covers the attributes, the postfix placement rule that applies only to [[reproducible]]/[[unsequenced]], the support matrix, and the features deliberately left out. --- .clang-format | 34 +++ .editorconfig | 17 ++ .github/workflows/c-build.yml | 130 +++++++---- .gitignore | 54 ++--- .vscode/c_cpp_properties.json | 43 ++-- .vscode/extensions.json | 13 ++ .vscode/launch.json | 49 +++- .vscode/settings.json | 84 +++++-- .vscode/tasks.json | 75 ++++-- Doxyfile | 32 +++ Makefile | 316 ++++++++++++++++++------- README.md | 199 +++++++++++++++- compile_flags.txt | 17 ++ docs/C23.md | 182 +++++++++++++++ include/linkedlist.h | 267 ++++++++++++++++++++-- src/linkedlist.c | 230 ++++++++++++------- tests/main.c | 419 ++++++++++++++++++++++------------ 17 files changed, 1677 insertions(+), 484 deletions(-) create mode 100644 .clang-format create mode 100644 .editorconfig create mode 100644 .vscode/extensions.json create mode 100644 Doxyfile create mode 100644 compile_flags.txt create mode 100644 docs/C23.md diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..04f3d6f --- /dev/null +++ b/.clang-format @@ -0,0 +1,34 @@ +--- +# clang-format has no dedicated "C" language key: plain C is formatted under +# the Cpp rules. `make format` applies this; `make format-check` enforces it. +Language: Cpp +BasedOnStyle: LLVM + +IndentWidth: 2 +TabWidth: 2 +UseTab: Never +ColumnLimit: 100 + +PointerAlignment: Right +AlignAfterOpenBracket: BlockIndent +AllowAllArgumentsOnNextLine: true +BinPackArguments: false +BinPackParameters: false + +AllowShortFunctionsOnASingleLine: None +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +AllowShortBlocksOnASingleLine: Never + +SpaceBeforeParens: ControlStatements +InsertBraces: true +InsertNewlineAtEOF: true + +SortIncludes: CaseInsensitive +IncludeBlocks: Preserve + +# C23 attributes such as [[nodiscard]] introduce a statement, so keep +# clang-format from gluing them onto the following declaration. +AttributeMacros: + - LINKEDLIST_REPRODUCIBLE + - LINKEDLIST_UNSEQUENCED diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..20bb508 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# Editor-agnostic baseline. VS Code honours this through the EditorConfig +# extension; clang-format owns the finer details of C formatting. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/c-build.yml b/.github/workflows/c-build.yml index ee0a5b2..2117852 100644 --- a/.github/workflows/c-build.yml +++ b/.github/workflows/c-build.yml @@ -2,64 +2,104 @@ name: C CI on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] workflow_dispatch: +# A new push to the same branch makes the previous run pointless. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: - test: + format: + name: clang-format runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + - name: Install clang-format + run: sudo apt-get update -y && sudo apt-get install -y clang-format-18 + - name: Check formatting + run: make format-check FORMAT=clang-format-18 + + test: + name: test (${{ matrix.os }}, ${{ matrix.cc }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # -std=c23 needs GCC >= 14 or Clang >= 18; both ship in the + # ubuntu-24.04 image that ubuntu-latest points at. Older toolchains + # only know the -std=c2x draft spelling and lack [[reproducible]]. + - os: ubuntu-latest + cc: gcc-14 + packages: gcc-14 + - os: ubuntu-latest + cc: clang-18 + packages: clang-18 + # Apple Clang has C23 but not [[reproducible]] / [[unsequenced]], + # so this leg is what proves the __has_c_attribute guards in + # include/linkedlist.h actually work. + - os: macos-latest + cc: cc + packages: "" + steps: + - uses: actions/checkout@v4 - - name: Install dependencies - run: | - sudo apt install software-properties-common -y - sudo add-apt-repository ppa:ubuntu-toolchain-r/test -y - sudo apt-get update -y - sudo apt-get install -y gcc-13 valgrind + - name: Install compiler + if: matrix.packages != '' + run: sudo apt-get update -y && sudo apt-get install -y ${{ matrix.packages }} - - name: Set up gcc-13 - run: | - sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-13 90 + - name: Check versions + run: | + ${{ matrix.cc }} --version + make --version | head -1 - - name: Check versions - run: | - gcc --version - make --version + - name: Build libraries + run: make build CC=${{ matrix.cc }} - - name: make build - run: make build + - name: Run tests + run: make test CC=${{ matrix.cc }} - - name: make test - run: make test + - name: Run tests under ASan + UBSan + run: make sanitize CC=${{ matrix.cc }} - - name: make memcheck - run: make memcheck + # Confirms the header still compiles for a consumer that only has the + # installed copy -- no ../include/ relative paths sneaking back in. + - name: Check install / uninstall + run: | + make install CC=${{ matrix.cc }} PREFIX="$PWD/staging" + test -f staging/include/linkedlist.h + make uninstall PREFIX="$PWD/staging" - build: + memcheck: + name: valgrind runs-on: ubuntu-latest - needs: test - if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + - name: Install dependencies + run: sudo apt-get update -y && sudo apt-get install -y gcc-14 valgrind + # Deliberately a plain build, not a sanitizer one: valgrind and ASan + # both hook the allocator and refuse to run together. + - name: Run tests under valgrind + run: make memcheck CC=gcc-14 - - name: Install dependencies - run: | - sudo apt install software-properties-common -y - sudo add-apt-repository ppa:ubuntu-toolchain-r/test -y - sudo apt-get update -y - sudo apt-get install -y gcc-13 valgrind - - - name: Set up gcc-13 - run: | - sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-13 90 - - - name: Check versions - run: | - gcc --version - make --version - - - name: make build - run: make build + docs: + name: doxygen + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install doxygen + run: sudo apt-get update -y && sudo apt-get install -y doxygen + - name: Build API docs + run: make docs + - uses: actions/upload-artifact@v4 + with: + name: api-docs + path: build/docs/html + retention-days: 7 diff --git a/.gitignore b/.gitignore index cca894a..6263346 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,18 @@ -# Prerequisites -*.d +# Everything the Makefile produces -- objects, .d dependency files, both +# libraries, the test binary, the sanitizer tree, and the Doxygen HTML -- goes +# under build/, so one entry covers the lot. +build/ + +# Debug symbol bundles produced by -g on macOS. +*.dSYM/ + +# clangd's --background-index writes its index here. +.cache/ + +# If you generate this with `bear -- make`, it holds absolute paths specific to +# one machine, so it should not be committed. compile_flags.txt is the checked-in +# alternative. +compile_commands.json # Object files *.o @@ -7,57 +20,28 @@ *.obj *.elf -# Linker output -*.ilk -*.map -*.exp - -# Precompiled Headers +# Precompiled headers *.gch *.pch # Libraries -*.lib *.a *.la *.lo +*.lib # Shared objects (inc. Windows DLLs) -*.dll *.so *.so.* *.dylib +*.dll # Executables *.exe *.out *.app -*.i*86 -*.x86_64 -*.hex - -# Debug files -*.dSYM/ -*.su -*.idb -*.pdb - -# Kernel Module Compile Results -*.mod* -*.cmd -.tmp_versions/ -modules.order -Module.symvers -Mkfile.old -dkms.conf - -# Extra files and directories -lib/ -obj/ -build/ -html/ -latex/ +# macOS Finder metadata .DS_Store ._.DS_Store **/.DS_Store diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json index b9aabf8..257ff91 100644 --- a/.vscode/c_cpp_properties.json +++ b/.vscode/c_cpp_properties.json @@ -1,30 +1,35 @@ { + // IntelliSense here is disabled in settings.json -- clangd does that job. + // This file still matters because the cpptools debugger reads it, and it + // keeps things working for anyone who turns the cpptools engine back on. + // + // "cStandard": "c23" needs cpptools 1.19+. Older releases only know "c17" + // and will quietly downgrade, which brings back the phantom errors on + // nullptr and [[nodiscard]]. "configurations": [ { "name": "macos-gcc-arm64", - "compilerPath": "/opt/homebrew/bin/gcc-13", + // Adjust to whatever `ls /opt/homebrew/bin/gcc-*` reports. The Makefile + // picks the newest one automatically; this path has to be spelled out. + "compilerPath": "/opt/homebrew/bin/gcc-16", + "compilerArgs": ["-std=c23"], "intelliSenseMode": "macos-gcc-arm64", - "includePath": [ - "${workspaceFolder}/**", - "${workspaceFolder}/include/**", - "/opt/homebrew/lib/gcc/13/**" - ], + "cStandard": "c23", + "cppStandard": "c++23", + "includePath": ["${workspaceFolder}/include/**", "${workspaceFolder}/src/**"], "defines": [], - "macFrameworkPath": [ - "${workspaceFolder}/**", - "/System/Library/Frameworks" - ], + "macFrameworkPath": ["/System/Library/Frameworks"] + }, + { + "name": "linux-gcc-x64", + "compilerPath": "/usr/bin/gcc", + "compilerArgs": ["-std=c23"], + "intelliSenseMode": "linux-gcc-x64", "cStandard": "c23", "cppStandard": "c++23", - "configurationProvider": "ms-vscode.makefile-tools", - "browse": { - "path": [ - "${workspaceFolder}/**", - "${workspaceFolder}/include/**", - "/opt/homebrew/lib/gcc/13/**" - ] - } + "includePath": ["${workspaceFolder}/include/**", "${workspaceFolder}/src/**"], + "defines": [] } ], "version": 4 -} \ No newline at end of file +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..29fe1c1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,13 @@ +{ + "recommendations": [ + // IntelliSense, completion, clang-tidy, and formatting for C23. + "llvm-vs-code-extensions.vscode-clangd", + // Kept for its debugger only; its IntelliSense engine is off in settings.json. + "ms-vscode.cpptools", + // Makefile target list in the sidebar. + "ms-vscode.makefile-tools", + // Honours .editorconfig. + "editorconfig.editorconfig" + ], + "unwantedRecommendations": [] +} diff --git a/.vscode/launch.json b/.vscode/launch.json index cb0b208..9b59b16 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,18 +1,57 @@ { - "version": "2.0.0", + // launch.json is versioned "0.2.0" -- unlike tasks.json, which is "2.0.0". + "version": "0.2.0", "configurations": [ { - "name": "C Debug -> linkedlist Makefile", + "name": "Debug tests (lldb, macOS)", "type": "cppdbg", "request": "launch", + // `make test` links the tests against the static library, so this binary + // needs no DYLD_LIBRARY_PATH to run under the debugger. "program": "${workspaceFolder}/build/test_linkedlist", "args": [], - "stopAtEntry": true, + "stopAtEntry": false, "cwd": "${workspaceFolder}", "environment": [], "externalConsole": false, "MIMode": "lldb", - "preLaunchTask": "make" + "preLaunchTask": "make test" + }, + { + "name": "Debug tests (gdb, Linux)", + "type": "cppdbg", + "request": "launch", + "program": "${workspaceFolder}/build/test_linkedlist", + "args": [], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [], + "externalConsole": false, + "MIMode": "gdb", + "setupCommands": [ + { + "description": "Enable pretty-printing for gdb", + "text": "-enable-pretty-printing", + "ignoreFailures": true + } + ], + "preLaunchTask": "make test" + }, + { + // The sanitizer build lives in its own tree so it never collides with + // the plain one. Run `make sanitize` first, then attach here to stop on + // the exact line ASan or UBSan reports. + "name": "Debug sanitizer build (lldb, macOS)", + "type": "cppdbg", + "request": "launch", + "program": "${workspaceFolder}/build/sanitize/test_linkedlist", + "args": [], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [{ "name": "ASAN_OPTIONS", "value": "abort_on_error=1:detect_leaks=1" }], + "externalConsole": false, + "MIMode": "lldb", + "preLaunchTask": "make sanitize" } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 5fd1a69..465def0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,24 +1,66 @@ { - "C_Cpp.errorSquiggles": "enabled", - "C_Cpp.enhancedColorization": "enabled", - "C_Cpp.intelliSenseEngine": "Tag Parser", + // --------------------------------------------------------------------- + // clangd drives IntelliSense; cpptools is kept only for its debugger. + // + // This split matters for C23 specifically. cpptools' own engine -- and + // especially its Tag Parser fallback -- does not understand `nullptr`, + // `constexpr`, or `[[attributes]]` in C, so it red-squiggles correct code. + // clangd is a real Clang frontend, reads compile_flags.txt at the repo + // root, and follows -std=c23 properly. + // --------------------------------------------------------------------- + "C_Cpp.intelliSenseEngine": "disabled", + // Without this, a user-level "enabled" makes cpptools fall back to the Tag + // Parser even when the engine is off -- which is what produces phantom + // errors on C23 syntax. + "C_Cpp.intelliSenseEngineFallback": "disabled", + "C_Cpp.autocomplete": "disabled", + "C_Cpp.errorSquiggles": "disabled", + "C_Cpp.formatting": "disabled", + // Stop VS Code from silently appending C++ standard headers to + // files.associations as "c", which is where the long junk list in the + // previous version of this file came from. + "C_Cpp.autoAddFileAssociations": false, + + // Left unset on purpose: the clangd extension finds clangd on PATH and + // offers to download one otherwise. Pin it only if you need a specific + // build, e.g. "clangd.path": "/opt/homebrew/opt/llvm/bin/clangd". + "clangd.arguments": [ + "--background-index", + "--clang-tidy", + "--header-insertion=never", + "--completion-style=detailed", + "--function-arg-placeholders=false", + "--pretty" + ], + + "[c]": { + "editor.defaultFormatter": "llvm-vs-code-extensions.vscode-clangd", + "editor.formatOnSave": true, + "editor.tabSize": 2, + "editor.insertSpaces": true, + "editor.rulers": [100] + }, + + // Without this, VS Code guesses C++ for .h files and clangd then parses + // this library's C23 header under C++ rules. "files.associations": { - "*.template": "yaml", - "cstdlib": "c", - "__hash_table": "c", - "__split_buffer": "c", - "array": "c", - "bitset": "c", - "deque": "c", - "initializer_list": "c", - "queue": "c", - "span": "c", - "stack": "c", - "string": "c", - "string_view": "c", - "unordered_map": "c", - "vector": "c", - "format": "c", - "__node_handle": "c" + "*.h": "c" + }, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + + "files.exclude": { + "**/build": true, + "**/.cache": true }, -} \ No newline at end of file + + "cSpell.words": [ + "clangd", + "cppdbg", + "linkedlist", + "memcheck", + "nodiscard", + "sonames", + "unsequenced" + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 5a9f8c7..e2130b7 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,18 +2,67 @@ "version": "2.0.0", "tasks": [ { - "type": "cppbuild", + // Default build task (Cmd/Ctrl+Shift+B). Every task shells out to the + // Makefile rather than restating the compiler flags, so the editor and + // CI can never drift apart on -std=c23. + "type": "shell", "label": "make", - "command": "make && make test", - "args": [], - "options": { - "cwd": "${workspaceFolder}" - }, - "problemMatcher": [ - "$gcc" - ], - "group": "build", - "detail": "Build our program using make" + "command": "make", + "osx": { "command": "make -j$(sysctl -n hw.ncpu)" }, + "linux": { "command": "make -j$(nproc)" }, + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": ["$gcc"], + "group": { "kind": "build", "isDefault": true }, + "detail": "Build the static and shared libraries (parallel)" + }, + { + "type": "shell", + "label": "make test", + "command": "make test", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": ["$gcc"], + "group": { "kind": "test", "isDefault": true }, + "detail": "Build and run the test suite" + }, + { + "type": "shell", + "label": "make sanitize", + "command": "make sanitize", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": ["$gcc"], + "detail": "Run the tests under AddressSanitizer + UndefinedBehaviorSanitizer" + }, + { + "type": "shell", + "label": "make memcheck", + "command": "make memcheck", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": [], + "detail": "Run the tests under leaks (macOS) / valgrind (Linux)" + }, + { + "type": "shell", + "label": "make format", + "command": "make format", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": [], + "detail": "Rewrite all sources using .clang-format" + }, + { + "type": "shell", + "label": "make docs-open", + "command": "make docs-open", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": [], + "detail": "Build the Doxygen HTML docs and open them" + }, + { + "type": "shell", + "label": "make clean", + "command": "make clean", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": [], + "detail": "Remove the build directory" } - ], -} \ No newline at end of file + ] +} diff --git a/Doxyfile b/Doxyfile new file mode 100644 index 0000000..955ba33 --- /dev/null +++ b/Doxyfile @@ -0,0 +1,32 @@ +# Minimal Doxygen config -- every option not listed keeps its default. +# Run with `make docs`, or `make docs-open` to build and open the result. + +PROJECT_NAME = linkedlist +PROJECT_BRIEF = "A C23 linked list, used as a library template" + +INPUT = include src README.md docs/C23.md +FILE_PATTERNS = *.h *.c *.md +RECURSIVE = YES +USE_MDFILE_AS_MAINPAGE = README.md + +OUTPUT_DIRECTORY = build/docs +GENERATE_HTML = YES +GENERATE_LATEX = NO + +OPTIMIZE_OUTPUT_FOR_C = YES +EXTRACT_ALL = YES +EXTRACT_STATIC = YES +SOURCE_BROWSER = YES +REFERENCED_BY_RELATION = YES + +WARN_IF_UNDOCUMENTED = YES +WARN_NO_PARAMDOC = YES + +HAVE_DOT = NO + +# The C23 attribute-portability macros expand to nothing useful in docs, so +# hide them rather than let them show up in every signature. +MACRO_EXPANSION = YES +EXPAND_ONLY_PREDEF = YES +PREDEFINED = LINKEDLIST_REPRODUCIBLE= \ + LINKEDLIST_UNSEQUENCED= diff --git a/Makefile b/Makefile index 8ac97cf..abb1524 100644 --- a/Makefile +++ b/Makefile @@ -1,119 +1,267 @@ -.DELETE_ON_ERROR: clean +# Build a C23 library (static + shared) plus its test binary. +# +# Quick start: +# make build both libraries +# make test build and run the test suite +# make sanitize run the tests under ASan + UBSan +# make memcheck run the tests under leaks (macOS) / valgrind (Linux) +# make help list every target +# +# Anything below can be overridden on the command line, e.g. +# make CC=clang +# make PREFIX=$HOME/.local install -# Where to find tools -TEST_APP = test_linkedlist -TARGET_LIB = linkedlistlib.so +# Delete a target file if its recipe fails, so a half-written .o or .a is never +# left behind looking up to date on the next run. +.DELETE_ON_ERROR: -CC_MACOS ?= /opt/homebrew/bin/gcc-13 -CC_LINUX ?= /usr/bin/gcc +LIB_NAME := linkedlist +TEST_APP := test_$(LIB_NAME) -AR_MACOS ?= /opt/homebrew/bin/gcc-ar-13 -AR_LINUX ?= /usr/bin/ar +# --------------------------------------------------------------------------- +# Toolchain +# --------------------------------------------------------------------------- -MEMCHECK_MACOS ?= /usr/bin/leaks -MEMCHECK_LINUX ?= /usr/bin/valgrind - -# Determine OS UNAME_S := $(shell uname -s) +ifeq ($(filter Darwin Linux,$(UNAME_S)),) + $(error Unsupported OS "$(UNAME_S)". This Makefile targets macOS and Linux.) +endif + +# -std=c23 needs GCC >= 14 or Clang >= 18. Apple Clang 16+ accepts it too, but +# it still lacks [[reproducible]] / [[unsequenced]], which the header +# feature-detects with __has_c_attribute rather than assuming. +# +# make always defines CC (to "cc"), so `?=` would never fire. Testing $(origin) +# instead means an explicit `make CC=clang` or `CC=clang make` still wins, and +# we only pick a default when nobody asked for anything. +ifeq ($(origin CC),default) + ifeq ($(UNAME_S),Darwin) + # Newest Homebrew GCC available, e.g. gcc-16. Apple Clang is the fallback. + BREW_PREFIX := $(shell brew --prefix 2>/dev/null || echo /opt/homebrew) + HOMEBREW_GCC := $(firstword $(shell ls $(BREW_PREFIX)/bin/gcc-[0-9]* 2>/dev/null | sort -Vr)) + CC := $(if $(HOMEBREW_GCC),$(HOMEBREW_GCC),cc) + else + CC := gcc + endif +endif + +AR ?= ar +DOXYGEN ?= doxygen +FORMAT ?= clang-format + +# leaks ships with macOS; valgrind is a package on Linux. Both are only needed +# by `make memcheck`, so a missing one is reported there, not here. ifeq ($(UNAME_S),Darwin) - MEMCHECK = $(MEMCHECK_MACOS) - MEMCHECK_ARGS = --atExit -- - CC = $(CC_MACOS) - AR = $(AR_MACOS) + MEMCHECK ?= leaks + MEMCHECK_ARGS ?= --atExit -- +else + MEMCHECK ?= valgrind + MEMCHECK_ARGS ?= --leak-check=full --show-leak-kinds=all --error-exitcode=1 endif -ifeq ($(UNAME_S),Linux) - MEMCHECK = $(MEMCHECK_LINUX) - MEMCHECK_ARGS = - CC = $(CC_LINUX) - AR = $(AR_LINUX) + +# --------------------------------------------------------------------------- +# Layout +# --------------------------------------------------------------------------- + +SRC_DIR := src +INC_DIR := include +TEST_DIR := tests + +# A sanitizer build uses different compiler flags, so it gets its own tree. +# Without this, `make test` and `make sanitize` would trade the same .o files +# back and forth and each would silently reuse the other's objects. +BUILD := build +ifeq ($(SANITIZE),1) + BUILD := build/sanitize endif -# Check if OS is supported -ifneq ($(UNAME_S),Darwin) - ifneq ($(UNAME_S),Linux) - $(error "Unsupported OS") - endif +OBJ_DIR := $(BUILD)/obj +TEST_OBJ_DIR := $(BUILD)/obj/tests +LIB_DIR := $(BUILD)/lib +DOCS_DIR := $(BUILD)/docs +DOCS_HTML := $(DOCS_DIR)/html/index.html + +# macOS and Linux disagree on both the extension and the flag that records a +# library's runtime name inside it. +ifeq ($(UNAME_S),Darwin) + SHARED_EXT := dylib + SHARED_NAME := lib$(LIB_NAME).$(SHARED_EXT) + SONAME_FLAG := -Wl,-install_name,@rpath/$(SHARED_NAME) +else + SHARED_EXT := so + SHARED_NAME := lib$(LIB_NAME).$(SHARED_EXT) + SONAME_FLAG := -Wl,-soname,$(SHARED_NAME) endif -# Check if executables are in PATH -EXECUTABLES = $(CC) $(AR) $(MEMCHECK) -K := $(foreach exec,$(EXECUTABLES),\ - $(if $(shell which $(exec)),some string,$(error "No $(exec) in PATH))) +STATIC_LIB := $(LIB_DIR)/lib$(LIB_NAME).a +SHARED_LIB := $(LIB_DIR)/$(SHARED_NAME) +TEST_BIN := $(BUILD)/$(TEST_APP) + +# Every .c under src/. Add a new file and it gets built automatically. +SRCS := $(wildcard $(SRC_DIR)/*.c) +OBJS := $(patsubst $(SRC_DIR)/%.c,$(OBJ_DIR)/%.o,$(SRCS)) +TEST_SRCS := $(wildcard $(TEST_DIR)/*.c) +TEST_OBJS := $(patsubst $(TEST_DIR)/%.c,$(TEST_OBJ_DIR)/%.o,$(TEST_SRCS)) -# Compiler and linker flags -CFLAGS = -Wall -Wextra -Werror -Wunused -O2 -g -std=c2x -pedantic # Compiler flags -LDFLAGS = -shared # Linker flags (shared library) (change to -static for static library) +# -MMD writes one .d file per object listing the headers it pulled in, so +# editing linkedlist.h rebuilds exactly what included it. +DEPS := $(OBJS:.o=.d) $(TEST_OBJS:.o=.d) -SRC_DIR := src -OBJ_DIR := obj -LIB_DIR := lib +FORMAT_FILES := $(SRCS) $(TEST_SRCS) $(wildcard $(INC_DIR)/*.h) -TEST_SRC_DIR := tests -TEST_OBJ_DIR := obj -BUILD_DIR := build +# --------------------------------------------------------------------------- +# Flags +# --------------------------------------------------------------------------- -SRC_FILES = $(wildcard $(SRC_DIR)/*.c) -OBJ_FILES = $(SRC_FILES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o) +# -std=c23 the 2023 standard, not the -std=c2x draft spelling +# -Wpedantic reject GNU extensions, so the code stays portable C23 +# -fPIC required for the shared library; harmless in the static one, +# and using one set of objects for both keeps the build simple +# -MMD -MP header dependency tracking (-MP adds dummy rules so deleting a +# header doesn't break the build with "no rule to make target") +CFLAGS ?= -O2 -g +CFLAGS += -std=c23 -Wall -Wextra -Werror -Wpedantic \ + -Wshadow -Wconversion -Wstrict-prototypes -Wwrite-strings \ + -Wcast-qual -Wundef -Wvla \ + -fPIC -MMD -MP -I$(INC_DIR) -TEST_FILES = $(wildcard $(TEST_SRC_DIR)/*.c) -TEST_OBJS = $(TEST_FILES:$(TEST_SRC_DIR)/%.c=$(TEST_OBJ_DIR)/%.o) +LDFLAGS ?= +LDLIBS ?= -INCLUDE_DIRS = -Iinclude +ifeq ($(SANITIZE),1) + # Both sanitizers instrument at compile time and need the same flags at link + # time, which is why they go in CFLAGS and LDFLAGS. + SAN_FLAGS := -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all + CFLAGS += $(SAN_FLAGS) + LDFLAGS += $(SAN_FLAGS) +endif + +# Install locations, used by `make install`. +PREFIX ?= /usr/local +DESTDIR ?= +INCLUDEDIR ?= $(PREFIX)/include +LIBDIR ?= $(PREFIX)/lib + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- -# Targets -##@ Default target +##@ Default .PHONY: all -all: clean build ## Clean and build the library +all: build ## Build the static and shared libraries + +##@ Build +.PHONY: build static shared +build: static shared ## Build both library flavors + +static: $(STATIC_LIB) ## Build the static library only + +shared: $(SHARED_LIB) ## Build the shared library only -##@ Build commands -.PHONY: clean build -build: $(TARGET_LIB) ## Clean and build the library +# Each recipe creates its own output directory with `mkdir -p $(@D)` instead +# of depending on a directory target. Directory targets would be tidier, but +# $(BUILD) is literally "build", which is also the name of the phony target +# above -- make merges the two, and every rule ordered after `| $(BUILD)` +# quietly picks up `static shared` as prerequisites. `make test` linking the +# shared library it never uses is how that shows up. +MKDIR = @mkdir -p $(@D) -$(TARGET_LIB): $(OBJ_FILES) | $(LIB_DIR) - $(CC) $(LDFLAGS) -o $(LIB_DIR)/$@ $^ +# The archive is rebuilt from scratch each time. Updating in place with `ar r` +# would keep objects belonging to source files that have since been deleted. +$(STATIC_LIB): $(OBJS) + $(MKDIR) + @rm -f $@ + $(AR) rcs $@ $^ -$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR) - $(CC) $(CFLAGS) $(INCLUDE_DIRS) -c -o $@ $< +$(SHARED_LIB): $(OBJS) + $(MKDIR) + $(CC) -shared $(LDFLAGS) -o $@ $^ $(LDLIBS) $(SONAME_FLAG) -$(TEST_APP): $(TEST_OBJS) $(LIB_DIR)/$(TARGET_LIB) | $(BUILD_DIR) - $(CC) $(CFLAGS) $(INCLUDE_DIRS) -o $(BUILD_DIR)/$@ $^ +$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c + $(MKDIR) + $(CC) $(CFLAGS) -c $< -o $@ -$(TEST_OBJ_DIR)/%.o: $(TEST_SRC_DIR)/%.c | $(TEST_OBJ_DIR) - $(CC) $(CFLAGS) $(INCLUDE_DIRS) -c -o $@ $< +# The tests link the static library so the binary runs straight from build/ +# with no rpath or DYLD_/LD_LIBRARY_PATH juggling. The shared library is still +# built and link-checked by `make build`. +$(TEST_BIN): $(TEST_OBJS) $(STATIC_LIB) + $(MKDIR) + $(CC) $(LDFLAGS) -o $@ $(TEST_OBJS) $(STATIC_LIB) $(LDLIBS) -$(BUILD_DIR): - @mkdir -p $(BUILD_DIR) +$(TEST_OBJ_DIR)/%.o: $(TEST_DIR)/%.c + $(MKDIR) + $(CC) $(CFLAGS) -c $< -o $@ -$(OBJ_DIR): - @mkdir -p $(OBJ_DIR) +##@ Test +.PHONY: test sanitize memcheck +test: $(TEST_BIN) ## Build and run the test suite + @echo "Running tests with $(notdir $(CC))..." + ./$(TEST_BIN) -$(LIB_DIR): - @mkdir -p $(LIB_DIR) +# Recursive make so the sanitizer flags are set before any object is compiled. +# SANITIZE=1 also redirects BUILD, so this never clobbers the normal build. +sanitize: ## Run the tests under AddressSanitizer + UndefinedBehaviorSanitizer + @$(MAKE) --no-print-directory test SANITIZE=1 -##@ Test commands -.PHONY: test -test: clean build $(TEST_APP) ## Run tests - @echo "Running tests..." - ./$(BUILD_DIR)/$(TEST_APP) +memcheck: $(TEST_BIN) ## Run the tests under leaks (macOS) / valgrind (Linux) + @command -v $(MEMCHECK) >/dev/null 2>&1 || { \ + echo "$(MEMCHECK) not found. Install it first."; exit 1; \ + } + $(MEMCHECK) $(MEMCHECK_ARGS) ./$(TEST_BIN) -.PHONY: memcheck -memcheck: test ## Run tests and check for memory leaks - @echo "Running tests with memory check..." - $(MEMCHECK) $(MEMCHECK_ARGS) ./$(BUILD_DIR)/$(TEST_APP) +##@ Docs +.PHONY: docs docs-open +# Doxygen only creates the last component of OUTPUT_DIRECTORY, so build/docs +# has to exist before it runs or a clean tree fails. The guard turns a +# confusing "doxygen: command not found" into instructions. +docs: ## Generate the HTML API docs from the Doxygen comments + @command -v $(DOXYGEN) >/dev/null 2>&1 || { \ + echo "$(DOXYGEN) not found. Install it with: brew install doxygen"; \ + exit 1; \ + } + @mkdir -p $(DOCS_DIR) + $(DOXYGEN) Doxyfile + @echo "Docs written to $(DOCS_HTML)" -##@ Clean commands +docs-open: docs ## Build the docs and open them in a browser + @command -v open >/dev/null 2>&1 && open $(DOCS_HTML) || xdg-open $(DOCS_HTML) + +##@ Format +.PHONY: format format-check +format: ## Rewrite all sources in place using .clang-format + $(FORMAT) -i $(FORMAT_FILES) + +format-check: ## Fail if any source is not formatted (used by CI) + $(FORMAT) --dry-run --Werror $(FORMAT_FILES) + +##@ Install +.PHONY: install uninstall +install: build ## Install headers and libraries under $$PREFIX (default /usr/local) + @mkdir -p $(DESTDIR)$(INCLUDEDIR) $(DESTDIR)$(LIBDIR) + install -m 644 $(INC_DIR)/$(LIB_NAME).h $(DESTDIR)$(INCLUDEDIR)/ + install -m 644 $(STATIC_LIB) $(DESTDIR)$(LIBDIR)/ + install -m 755 $(SHARED_LIB) $(DESTDIR)$(LIBDIR)/ + +uninstall: ## Remove what `make install` installed + rm -f $(DESTDIR)$(INCLUDEDIR)/$(LIB_NAME).h + rm -f $(DESTDIR)$(LIBDIR)/lib$(LIB_NAME).a + rm -f $(DESTDIR)$(LIBDIR)/$(SHARED_NAME) + +##@ Clean .PHONY: clean -clean: ## Clean built artifacts - @rm -rf $(BUILD_DIR) - @rm -rf $(OBJ_DIR) - @rm -rf $(LIB_DIR) +clean: ## Remove every build artifact + @rm -rf build -##@ Help commands +##@ Help .PHONY: help help: ## Display this help - @awk 'BEGIN {FS = ":.*##"; \ - printf "Usage: make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ \ - { printf " \033[36m%-10s\033[0m %s\n", $$1, $$2 } /^##@/ \ - { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' \ - $(MAKEFILE_LIST) \ No newline at end of file + @awk 'BEGIN {FS = ":.*##"; \ + printf "Usage: make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ \ + { printf " \033[36m%-14s\033[0m %s\n", $$1, $$2 } /^##@/ \ + { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' \ + $(MAKEFILE_LIST) + +# Pull in the generated header dependencies. The leading '-' ignores them on a +# clean tree, where they don't exist yet. +-include $(DEPS) diff --git a/README.md b/README.md index 16fc7b3..c8a2241 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,199 @@ # c-library-template -This is a template for a C library. +A starting point for a C library written against **C23**, with the build, +editor, and CI wiring already done. The linked list it ships with is a working +example, not the point — replace it. -It includes +- Static (`.a`) and shared (`.dylib` / `.so`) builds from one set of objects +- `-std=c23` with a warning set that includes `-Werror -Wconversion -Wshadow` +- Header dependency tracking, so editing a header rebuilds exactly what used it +- Test suite that does not disappear under `-DNDEBUG` +- AddressSanitizer + UndefinedBehaviorSanitizer, plus `leaks` / `valgrind` +- clangd-based IntelliSense that actually understands C23 attributes +- `clang-format`, Doxygen, and a GitHub Actions matrix across GCC, Clang, and + Apple Clang -- [x] A Makefile with targets for building the library and running tests -- [x] A basic test suite -- [x] A basic library structure -- [x] A `vscode` configuration for debugging +## Requirements -## Usage +| Tool | Version | Needed for | +| ------------ | ------- | ----------------------- | +| GCC | ≥ 14 | `-std=c23` | +| or Clang | ≥ 18 | `-std=c23` | +| GNU Make | ≥ 3.81 | the build | +| clang-format | any | `make format` | +| Doxygen | any | `make docs` | +| valgrind | any | `make memcheck` (Linux) | -To use this template, clone the repository and run the following commands: +macOS: ```bash -make -make test +brew install gcc clang-format doxygen ``` +Debian / Ubuntu: + +```bash +sudo apt-get install gcc-14 clang-format doxygen valgrind +``` + +The Makefile picks the newest `gcc-NN` in your Homebrew prefix on macOS and +`gcc` on Linux. Override it whenever you like: + +```bash +make CC=clang test +``` + +## Targets + +```bash +make help +``` + +| Target | What it does | +| ------------------- | -------------------------------------------------------------------- | +| `make` | Build the static and shared libraries | +| `make static` | Static library only | +| `make shared` | Shared library only | +| `make test` | Build and run the test suite | +| `make sanitize` | Run the tests under ASan + UBSan (separate `build/sanitize/` tree) | +| `make memcheck` | Run the tests under `leaks` (macOS) or `valgrind` (Linux) | +| `make format` | Rewrite sources with `.clang-format` | +| `make format-check` | Fail if anything is unformatted — what CI runs | +| `make docs` | Generate HTML API docs into `build/docs/html` | +| `make docs-open` | Build the docs and open them | +| `make install` | Install headers and libraries under `$PREFIX` (default `/usr/local`) | +| `make clean` | Delete `build/` | + +`make install` honours `PREFIX` and `DESTDIR`: + +```bash +make install PREFIX="$HOME/.local" +make install DESTDIR=/tmp/stage PREFIX=/usr +``` + +## Layout + +```text +include/linkedlist.h public header — the only file consumers need +src/linkedlist.c implementation +tests/main.c test suite and its runner +docs/C23.md which C23 features are used, and why +compile_flags.txt flags clangd applies to every file +.clang-format formatting rules, enforced by CI +Doxyfile API doc configuration +build/ everything generated; git-ignored, safe to delete +``` + +Everything the build produces lives under `build/` — objects, `.d` dependency +files, both libraries, the test binary, the sanitizer tree, and the docs. There +are no stray `obj/` or `lib/` directories at the repo root. + +## Editor setup + +Install the recommended extensions when VS Code offers them +([.vscode/extensions.json](.vscode/extensions.json)); the important one is +**clangd**. + +IntelliSense is clangd's job here and cpptools' engine is switched off. That is +not a preference — cpptools, and especially its Tag Parser fallback, does not +parse `nullptr`, `constexpr`, or `[[nodiscard]]` in C, so it marks correct C23 +code as broken. cpptools is still installed because its debugger is the one +`launch.json` uses. + +clangd reads [compile_flags.txt](compile_flags.txt), so it needs no build +integration. Keep `-std=c23` in that file in sync with `CFLAGS` in the Makefile. +If the project later grows per-file flags, generate a real +`compile_commands.json` with `bear -- make` and delete `compile_flags.txt` — +clangd prefers the former when both exist. + +`F5` debugs the test binary. `Cmd/Ctrl+Shift+B` builds. + +## Using the library + +```c +#include +#include + +int main(void) { + List *list = list_new(); + if (list == nullptr) { + return 1; + } + + const int value = 42; + if (!list_append_value(list, &value, sizeof value)) { + list_destroy(list); + return 1; + } + + // Or skip the temporary entirely — typeof works out the size: + LIST_APPEND_LITERAL(list, 3.5); + + printf("%zu elements\n", list_size(list)); + + list_destroy(list); // Frees every node and payload. +} +``` + +```bash +cc -std=c23 -Iinclude example.c build/lib/liblinkedlist.a -o example +``` + +Two ownership rules cover the whole API: + +- `list_append_value` / `list_prepend_value` **copy** the bytes you hand them. + You keep the original and may free it immediately. +- `list_append` / `list_prepend` **take ownership** of a `Node *` you allocated + with `list_node_new`. `list_destroy` frees it. + +Nodes that come back out of `list_pop` belong to you again — pass them to +`list_node_destroy` or insert them into another list. + +## Making it your own + +1. Rename `LIB_NAME` in the [Makefile](Makefile) — the archive, shared library, + soname, and install paths all follow from it. +2. Replace `include/linkedlist.h`, `src/linkedlist.c`, and `tests/main.c`. +3. Update `PROJECT_NAME` and `PROJECT_BRIEF` in the [Doxyfile](Doxyfile). +4. Point `program` in [.vscode/launch.json](.vscode/launch.json) at the new test + binary name. +5. Adjust `compilerPath` in + [.vscode/c_cpp_properties.json](.vscode/c_cpp_properties.json) to a compiler + you actually have. + +Adding a `.c` file to `src/` or `tests/` needs no Makefile change — both +directories are globbed. + +### Before shipping a real shared library + +The shared library here is unversioned: it records a soname of +`liblinkedlist.so` (or an install name of `@rpath/liblinkedlist.dylib`). That is +fine for a template and for static linking. A library you actually distribute +needs a version in the soname (`liblinkedlist.so.1`) plus the usual symlink +chain, so consumers keep working when you break ABI. + +## C23 notes + +[docs/C23.md](docs/C23.md) covers which C23 features this template uses, the +placement rule that makes `[[reproducible]]` and `[[unsequenced]]` different +from every other attribute, how `__has_c_attribute` keeps the header compiling +on Clang, and which features were deliberately left out. + +## CI + +[.github/workflows/c-build.yml](.github/workflows/c-build.yml) runs on every +push and pull request: + +- **format** — `clang-format` diff check +- **test** — build, test, and sanitize on Ubuntu with GCC 14 and Clang 18, and + on macOS with Apple Clang; then a staged `make install` / `make uninstall` +- **memcheck** — valgrind against a non-sanitizer build (the two cannot share a + process) +- **docs** — Doxygen build, uploaded as an artifact + +The macOS leg is not redundant: Apple Clang has C23 but not `[[reproducible]]`, +so it is what proves the attribute guards in the header work. + ## License -This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. -` \ No newline at end of file +Apache License 2.0. See [LICENSE](LICENSE). diff --git a/compile_flags.txt b/compile_flags.txt new file mode 100644 index 0000000..83bf5b7 --- /dev/null +++ b/compile_flags.txt @@ -0,0 +1,17 @@ +# Flags clangd applies to every file in this project, one per line. +# +# This is the low-ceremony alternative to compile_commands.json: it needs no +# build-system integration and no `bear`, which is what makes it right for a +# template. If this library later grows per-file flags, generate a real +# compile_commands.json instead (`bear -- make`) and delete this file -- +# clangd prefers compile_commands.json when both are present. +# +# Keep -std in sync with CFLAGS in the Makefile. +-std=c23 +-Wall +-Wextra +-Wpedantic +-Wshadow +-Wconversion +-Wstrict-prototypes +-Iinclude diff --git a/docs/C23.md b/docs/C23.md new file mode 100644 index 0000000..886a9e2 --- /dev/null +++ b/docs/C23.md @@ -0,0 +1,182 @@ +# C23 in this template + +What the C23 standard (ISO/IEC 9899:2024, `__STDC_VERSION__ == 202311L`) changes +for a small C library, which of it this template uses, and where the compilers +still disagree. + +## Selecting the standard + +Use `-std=c23`, not `-std=c2x`. `c2x` was the draft spelling and is now a +deprecated alias — GCC 14 warns about it and it will eventually go away. The +Makefile passes `-std=c23`; [compile_flags.txt](../compile_flags.txt) repeats it +for clangd, and `cStandard: "c23"` in +[.vscode/c_cpp_properties.json](../.vscode/c_cpp_properties.json) repeats it +again for the cpptools debugger. All three have to agree. + +| Toolchain | First release with usable C23 | Notes | +| ------------ | ----------------------------- | ------------------------------------------------------- | +| GCC | 14 (15+ recommended) | Only compiler with `[[reproducible]]`/`[[unsequenced]]` | +| Clang / LLVM | 18 | `-std=c23` accepted from 18 | +| Apple Clang | Xcode 16 | Follows upstream Clang; no `[[reproducible]]` | +| MSVC | partial | `/std:clatest`; not targeted here | + +[include/linkedlist.h](../include/linkedlist.h) opens with an `#error` on +`__STDC_VERSION__ < 202311L` so a pre-C23 compiler says so plainly instead of +emitting a wall of syntax errors on `nullptr`. + +## Attributes + +C23 adopts the `[[...]]` attribute syntax from C++ and defines seven standard +attributes. All of them may be written `[[gnu::...]]`-style for vendor +extensions too, but the seven below are portable. + +| Attribute | Applies to | Effect | Used here | +| ------------------ | ---------------------------------- | ------------------------------------------------ | --------------------------------- | +| `[[nodiscard]]` | functions, struct/union/enum types | Warn when the return value is ignored | Every non-`void` function | +| `[[maybe_unused]]` | declarations, parameters, members | Suppress "unused" warnings | Documented below; not needed yet | +| `[[deprecated]]` | almost any declaration | Warn on use | Documented below | +| `[[fallthrough]]` | a null statement inside `switch` | Allow deliberate fallthrough without a warning | No `switch` in this library | +| `[[noreturn]]` | functions | The function never returns | `die()` in tests/main.c | +| `[[reproducible]]` | function **types** | Effectless and idempotent | `list_size`, `list_is_empty` | +| `[[unsequenced]]` | function **types** | Also stateless and independent — a pure function | Macro provided; nothing qualifies | + +Both `[[nodiscard]]` and `[[deprecated]]` take an optional message: +`[[nodiscard("the caller must free this")]]`. + +### The placement trap + +`[[reproducible]]` and `[[unsequenced]]` attach to the *function type*, not the +declaration, so they go **after** the parameter list: + +```c +[[nodiscard]] size_t list_size(const List *list) [[reproducible]]; // correct +[[reproducible]] size_t list_size(const List *list); // wrong +``` + +GCC rejects the second form with *"standard 'reproducible' attribute can only be +applied to function declarators or type specifiers with function type"* and +helpfully suggests the fix. The other five attributes use the familiar leading +position. + +### `reproducible` vs `unsequenced` + +- **`reproducible`** — *effectless* (no observable side effects) and + *idempotent* (calling it twice in a row is the same as once). +- **`unsequenced`** — all of the above plus *stateless* (no internal state) and + *independent* (the result depends only on the arguments themselves). + +Every function in this library reads through a pointer parameter, so the result +depends on memory the caller did not pass by value. That fails *independent*, +which is why `list_size` is marked `reproducible` and nothing here is +`unsequenced`. `unsequenced` is for things like `int square(int n)`. + +Getting this wrong is not a warning — it is a promise to the optimizer. If a +function marked `unsequenced` actually reads global state, the compiler is free +to cache a stale result. + +### Feature-detecting attributes portably + +Clang does not implement `[[reproducible]]` or `[[unsequenced]]` yet, and +clangd — which is a Clang frontend — would red-squiggle every declaration using +them. `__has_c_attribute` is the standard way to ask: + +```c +#if defined(__has_c_attribute) +# if __has_c_attribute(reproducible) +# define LINKEDLIST_REPRODUCIBLE [[reproducible]] +# endif +#endif +#ifndef LINKEDLIST_REPRODUCIBLE +# define LINKEDLIST_REPRODUCIBLE +#endif +``` + +Test for **non-zero**, not for `>= 202311L`. `__has_c_attribute` returns the +attribute's standardisation date, and Clang reports the C++ paper dates rather +than the C23 ones: + +| Attribute | GCC 16 | Apple Clang 21 | +| -------------- | -------- | -------------- | +| `nodiscard` | `202311` | `202003` | +| `maybe_unused` | `202311` | `202106` | +| `deprecated` | `202311` | `201904` | +| `fallthrough` | `202311` | `201910` | +| `noreturn` | `202311` | `202202` | +| `reproducible` | `202311` | `0` | +| `unsequenced` | `202311` | `0` | + +A `>= 202311L` check would therefore disable `[[nodiscard]]` on Clang, which +supports it perfectly well. `.clang-format` lists both macros under +`AttributeMacros` so the formatter keeps treating them as attributes rather than +as a return type. + +### The two not used in the library code + +They have no honest home in a linked list, but this is what they look like: + +```c +// A parameter kept for API compatibility, or one used only in debug builds. +static void on_event([[maybe_unused]] void *user_data) { } + +// Softly retiring an API. The message shows up in the compiler diagnostic. +[[deprecated("use list_append_value instead")]] bool list_add(List *, void *); + +switch (kind) { + case A: + setup(); + [[fallthrough]]; // a statement, so it needs the semicolon + case B: + run(); + break; +} +``` + +`[[noreturn]]` replaces C11's `_Noreturn` keyword and the `noreturn` macro from +``; both of those are deprecated in C23. + +## Other C23 features used here + +| Feature | Where | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `nullptr` / `nullptr_t` | Everywhere `NULL` used to be. It has a real type, so it never silently converts to `int` in a variadic call. | +| `bool`, `true`, `false` | Keywords now — the library never includes ``. | +| `static_assert` | A keyword, so no ``. The message argument is optional in C23. | +| `constexpr` | `ITEM_COUNT` in tests/main.c. Unlike an `enum` constant it carries a real type; unlike `const size_t` it is a constant expression. C23 has it for *objects* only, not functions. | +| `typeof` | The `LIST_APPEND_LITERAL` macro, and the `static_assert` in src/linkedlist.c. A GNU extension for 30 years, now standard, so it survives `-Wpedantic`. | +| `(void)` prototypes | C23 removed K&R declarations, so an empty `()` now means `(void)`. Written out anyway — `-Wstrict-prototypes` is on and old habits read as ambiguous. | +| Designated initializers | The `TestCase` table (C99, but worth keeping consistent). | + +## Deliberately not used + +- **`#embed`** — no binary assets to inline. +- **`_BitInt(N)`** — no bit-exact arithmetic here. +- **`auto` type inference** — legal in C23, but it hides types in a codebase + small enough to read, and it changes the meaning of a keyword that used to be + a storage class. +- **`%b` / `%B` printf conversions and binary literals (`0b1010`)** — nothing + formats bit patterns. +- **Digit separators (`1'000'000`)** — no long literals. +- **`unreachable()`** from `` — no exhaustive `switch` to terminate. +- **``** (`ckd_add`, `ckd_sub`, `ckd_mul`) — worth reaching for the + moment a library computes an allocation size from user-supplied values, e.g. + `count * sizeof(T)`. This list allocates exactly the `size_t` it is handed, + so there is nothing to overflow. +- **`memset_explicit`** — nothing here holds secrets that must be scrubbed. + +## Behaviour changes worth knowing + +- `realloc(ptr, 0)` is **undefined behaviour** in C23. It used to be + implementation-defined. Free explicitly instead. +- Empty initializers `= {}` are now standard for any object, including VLAs and + structs whose first member is not scalar. +- `strdup` and `strndup` moved from POSIX into the C standard library. +- `free_sized` and `free_aligned_sized` were added. +- Labels may appear at the end of a compound statement, so a trailing + `cleanup:` before `}` no longer needs a dummy `;`. + +## References + +- [Attributes in C23 (and C++)](https://dev.to/pauljlucas/attributes-in-c23-and-c-5eg) — Paul J. Lucas +- [cppreference: C language](https://en.cppreference.com/c) — per-feature support tables +- [GCC C23 status](https://gcc.gnu.org/projects/c-status.html) +- [Clang C status](https://clang.llvm.org/c_status.html) diff --git a/include/linkedlist.h b/include/linkedlist.h index 7140fd6..efb0a46 100644 --- a/include/linkedlist.h +++ b/include/linkedlist.h @@ -1,34 +1,263 @@ #ifndef LINKEDLIST_H #define LINKEDLIST_H -#include +/** + * \file linkedlist.h + * + * A singly linked list that stores a byte-wise copy of each element. + * + * The list owns everything it holds. Values handed to \ref list_append_value + * and \ref list_prepend_value are copied into freshly allocated storage, so + * the caller keeps ownership of the original and may free it, reuse it, or + * let it go out of scope immediately afterwards. Nodes handed to + * \ref list_append and \ref list_prepend are the opposite: the list takes + * ownership and frees them in \ref list_destroy. + * + * Because each node records the \ref Node::size of its payload, one list can + * hold elements of different types. Reading them back is the caller's problem + * -- nothing here checks that the \c void* you get out matches the type you + * put in. + * + * This header is written against C23 and uses `nullptr`, `bool` as a keyword, + * and `[[attributes]]`. See docs/C23.md for what each feature buys and how + * the attribute portability macros below work. + */ -typedef struct Node -{ - // size of the data type +// A hard error beats the cascade of confusing syntax errors a pre-C23 +// compiler would otherwise produce on `nullptr` and `[[nodiscard]]`. +#if !defined(__STDC_VERSION__) || __STDC_VERSION__ < 202311L +#error "linkedlist.h requires C23. Compile with -std=c23 (GCC >= 14, Clang >= 18)." +#endif + +// size_t only. C23 also puts nullptr_t and unreachable() here, but +// -- which this header used to include -- drags in the whole stdio surface +// just to reach the same typedef. +#include + +/** + * \def LINKEDLIST_REPRODUCIBLE + * + * Expands to `[[reproducible]]` where the compiler has it, and to nothing + * otherwise. + * + * `[[reproducible]]` and `[[unsequenced]]` are the two attributes C23 added + * that C++ has no equivalent for, and as of writing GCC 14+ implements them + * while Clang does not. `__has_c_attribute` is the standard way to ask, and + * guarding on it is what keeps this header usable under both compilers -- and + * under clangd, which would otherwise red-squiggle every declaration below. + * + * Both are *type* attributes appertaining to the function type, so unlike + * `[[nodiscard]]` they go **after** the parameter list: + * + * ```c + * [[nodiscard]] size_t list_size(const List *list) LINKEDLIST_REPRODUCIBLE; + * ``` + * + * Placing them in the leading position is a constraint violation; GCC + * diagnoses it with "can only be applied to function declarators". + */ +#if defined(__has_c_attribute) +#if __has_c_attribute(reproducible) +#define LINKEDLIST_REPRODUCIBLE [[reproducible]] +#endif +#if __has_c_attribute(unsequenced) +#define LINKEDLIST_UNSEQUENCED [[unsequenced]] +#endif +#endif + +#ifndef LINKEDLIST_REPRODUCIBLE +#define LINKEDLIST_REPRODUCIBLE +#endif + +/** + * \def LINKEDLIST_UNSEQUENCED + * + * Expands to `[[unsequenced]]` where the compiler has it, and to nothing + * otherwise. See \ref LINKEDLIST_REPRODUCIBLE for the placement rule. + * + * Nothing in this library qualifies: `unsequenced` additionally requires the + * function be *stateless* and *independent*, and every function here reads + * through a pointer parameter, which is state the caller did not pass by + * value. `reproducible` -- effectless and idempotent -- is the honest choice + * for those. The macro is defined anyway so code built on this template can + * use it without repeating the feature test. + */ +#ifndef LINKEDLIST_UNSEQUENCED +#define LINKEDLIST_UNSEQUENCED +#endif + +/** + * One element of the list, together with the payload it owns. + */ +typedef struct Node { + /** Size of \ref data in bytes, as passed when the node was created. */ size_t size; + /** The element itself: \ref size bytes owned by this node, or `nullptr`. */ void *data; + /** The next element, or `nullptr` at the end of the list. */ struct Node *next; } Node; -typedef struct List -{ +/** + * The list itself. + * + * \ref tail exists so \ref list_append is O(1) instead of walking the chain. + * This is not a circular list: the last node's `next` is always `nullptr`. + */ +typedef struct List { + /** First element, or `nullptr` when the list is empty. */ Node *head; - size_t size; - - // used to have a reference to the last node, but - // this is not a circular linked list + /** Last element, or `nullptr` when the list is empty. */ Node *tail; + /** Number of elements currently in the list. */ + size_t size; } List; -List *list_new(); +/** + * Allocate an empty list. + * + * \return a new empty list the caller must pass to \ref list_destroy, or + * `nullptr` if the allocation failed. + * + * \sa list_destroy + */ +[[nodiscard]] List *list_new(void); + +/** + * Free a list and every node still in it, payloads included. + * + * Passing `nullptr` is a no-op, which mirrors `free` and means teardown paths + * do not need their own null check. + * + * \param list the list to free. Unusable afterwards. + */ void list_destroy(List *list); + +/** + * Allocate a node holding a copy of \p value. + * + * \p size bytes are copied out of \p value, so the caller keeps ownership of + * the original. A \p size of 0 produces a node whose \ref Node::data is + * `nullptr`, which is the only case where \p value may itself be `nullptr`. + * + * \param value the element to copy in. + * \param size the number of bytes to copy, typically `sizeof(T)`. + * \return a detached node the caller must either insert into a list or free + * with \ref list_node_destroy, or `nullptr` if the allocation failed + * or the arguments were inconsistent. + * + * \sa list_node_destroy + */ +[[nodiscard]] Node *list_node_new(const void *value, size_t size); + +/** + * Free a single node and its payload. + * + * Only call this on a node that is *not* in a list -- one you got back from + * \ref list_pop or \ref list_node_new. Freeing a node that is still linked + * leaves the list pointing at released memory. + * + * Passing `nullptr` is a no-op. + * + * \param node the node to free. + */ void list_node_destroy(Node *node); -void list_prepend(List *list, Node *node); -void list_append(List *list, Node *node); -void list_prepend_value(List *list, void *value, size_t size); -void list_append_value(List *list, void *value, size_t size); -Node *list_pop(List *list); -size_t list_size(List *list); - -#endif // LINKEDLIST_H \ No newline at end of file + +/** + * Insert an existing node at the front of the list. + * + * On success the list takes ownership of \p node and will free it in + * \ref list_destroy. On failure ownership stays with the caller. + * + * \param list the list to insert into. + * \param node the node to insert. + * \return `true` on success, `false` if either argument was `nullptr`. + */ +[[nodiscard]] bool list_prepend(List *list, Node *node); + +/** + * Insert an existing node at the back of the list, in O(1). + * + * Ownership follows the same rule as \ref list_prepend. + * + * \param list the list to insert into. + * \param node the node to insert. + * \return `true` on success, `false` if either argument was `nullptr`. + */ +[[nodiscard]] bool list_append(List *list, Node *node); + +/** + * Copy \p value into a new node at the front of the list. + * + * Equivalent to \ref list_node_new followed by \ref list_prepend, without the + * intermediate node to clean up on failure. + * + * \param list the list to insert into. + * \param value the element to copy in. + * \param size the number of bytes to copy, typically `sizeof(T)`. + * \return `true` on success, `false` on allocation failure or bad arguments. + */ +[[nodiscard]] bool list_prepend_value(List *list, const void *value, size_t size); + +/** + * Copy \p value into a new node at the back of the list. + * + * \param list the list to insert into. + * \param value the element to copy in. + * \param size the number of bytes to copy, typically `sizeof(T)`. + * \return `true` on success, `false` on allocation failure or bad arguments. + */ +[[nodiscard]] bool list_append_value(List *list, const void *value, size_t size); + +/** + * Detach and return the first node. + * + * The node comes back with its `next` cleared and its payload intact, and the + * caller becomes responsible for it -- pass it to \ref list_node_destroy or + * insert it into another list. + * + * \param list the list to pop from. + * \return the former head, or `nullptr` if \p list was `nullptr` or empty. + * + * \sa list_node_destroy + */ +[[nodiscard]] Node *list_pop(List *list); + +/** + * Number of elements in the list. + * + * \param list the list to measure, or `nullptr`. + * \return the element count, or 0 for a `nullptr` list. + */ +[[nodiscard]] size_t list_size(const List *list) LINKEDLIST_REPRODUCIBLE; + +/** + * Whether the list holds no elements. + * + * \param list the list to test, or `nullptr`. + * \return `true` when the list is empty or `nullptr`. + */ +[[nodiscard]] bool list_is_empty(const List *list) LINKEDLIST_REPRODUCIBLE; + +/** + * \def LIST_APPEND_LITERAL + * + * Append the value of an expression without naming its type or taking its + * address by hand. + * + * ```c + * LIST_APPEND_LITERAL(list, 42); // appends an int + * LIST_APPEND_LITERAL(list, 3.5); // appends a double + * ``` + * + * C23 standardised `typeof`, which had been a GNU extension for decades, so + * this now compiles under `-Wpedantic`. The compound literal it builds lives + * until the end of the enclosing block, which is long enough because + * \ref list_append_value copies out of it before returning. + * + * \p expr is evaluated exactly once. + */ +#define LIST_APPEND_LITERAL(list, expr) \ + list_append_value((list), &(typeof(expr)){(expr)}, sizeof(typeof(expr))) + +#endif // LINKEDLIST_H diff --git a/src/linkedlist.c b/src/linkedlist.c index 0c8ac4d..98f6cb3 100644 --- a/src/linkedlist.c +++ b/src/linkedlist.c @@ -1,138 +1,206 @@ -#include "../include/linkedlist.h" -#include +/** + * \file linkedlist.c + * + * Implementation of the list declared in include/linkedlist.h. + * + * Two invariants hold between every public call and are worth keeping in mind + * when editing: + * + * 1. `size == 0` if and only if `head == nullptr` and `tail == nullptr`. + * 2. The last node's `next` is always `nullptr`. This is not a circular list. + */ + +#include "linkedlist.h" + #include +#include + +// C23 promotes static_assert to a keyword (no needed) and +// standardises typeof. list_node_new passes Node::size straight to malloc, so +// pin the type down here instead of leaving the assumption implicit. The +// pointer is never dereferenced: typeof, like sizeof, does not evaluate its +// operand. +static_assert( + sizeof(typeof(((Node *)nullptr)->size)) == sizeof(size_t), + "Node::size must stay a size_t byte count" +); + +List *list_new(void) { + List *list = malloc(sizeof(List)); + if (list == nullptr) { + return nullptr; + } -List *list_new() -{ - List *l = (List *)malloc(sizeof(List)); - l->head = NULL; - l->tail = NULL; - l->size = 0; + list->head = nullptr; + list->tail = nullptr; + list->size = 0; - return l; + return list; } -void list_destroy(List *list) -{ - if (list->head == NULL) - { - free(list); +void list_destroy(List *list) { + // Tolerating nullptr mirrors free() and keeps error paths short: a caller + // whose list_new() failed can still fall through to cleanup. + if (list == nullptr) { return; } - Node *temp_node = NULL; + Node *node = list->head; + while (node != nullptr) { + // Read next before freeing, not after. + Node *next = node->next; + list_node_destroy(node); + node = next; + } + + free(list); +} - while (list->head != NULL) - { - temp_node = list->head; - list->head = temp_node->next; +Node *list_node_new(const void *value, size_t size) { + // A zero-size element is the one case where value may be null. Any other + // null value would leave the payload uninitialised, so reject it. + if (value == nullptr && size != 0) { + return nullptr; + } - list_node_destroy(temp_node); + Node *node = malloc(sizeof(Node)); + if (node == nullptr) { + return nullptr; } - free(list); + node->next = nullptr; + node->size = size; + node->data = nullptr; + + if (size == 0) { + // malloc(0) may legally return either nullptr or a unique pointer, so + // normalise on nullptr instead of letting the platform decide. + return node; + } + + node->data = malloc(size); + if (node->data == nullptr) { + free(node); + return nullptr; + } + + // The copy is the whole point: the caller keeps ownership of `value` and + // may free it the moment this returns. + memcpy(node->data, value, size); + + return node; } -void list_node_destroy(Node *node) -{ +void list_node_destroy(Node *node) { + if (node == nullptr) { + return; + } + free(node->data); free(node); } -void list_prepend(List *list, Node *node) -{ - if (list == NULL || node == NULL) - { - return; +bool list_prepend(List *list, Node *node) { + if (list == nullptr || node == nullptr) { + return false; } - // store the pointer to the first element prepend to the list - // to keep track of the tail of the list - if (list->size == 0) - { + // An empty list has no tail yet, and the incoming node becomes both ends. + if (list->head == nullptr) { list->tail = node; } node->next = list->head; list->head = node; list->size++; + + return true; } -void list_append(List *list, Node *node) -{ - if (list == NULL || node == NULL) - { - return; +bool list_append(List *list, Node *node) { + if (list == nullptr || node == nullptr) { + return false; } - node->next = NULL; + node->next = nullptr; - // store the pointer to the first element prepend to the list - // to keep track of the tail of the list - if (list->size == 0) - { + if (list->head == nullptr) { list->head = node; list->tail = node; + list->size++; + // Returning here matters. Falling through to `list->tail->next = node` + // below would set node->next to node itself and make a one-element list + // circular, which list_destroy would then walk forever. + return true; } - // add the node to the tail of the list list->tail->next = node; list->tail = node; - list->size++; -} -size_t list_size(List *list) -{ - return list->size; + return true; } -void list_prepend_value(List *list, void *value, size_t size) -{ - Node *node = malloc(sizeof(Node)); - node->next = NULL; +bool list_prepend_value(List *list, const void *value, size_t size) { + if (list == nullptr) { + return false; + } - node->data = malloc(size); - node->data = value; - node->size = size; + Node *node = list_node_new(value, size); + if (node == nullptr) { + return false; + } + + if (!list_prepend(list, node)) { + list_node_destroy(node); + return false; + } - list_prepend(list, node); + return true; } -void list_append_value(List *list, void *value, size_t size) -{ - Node *node = malloc(sizeof(Node)); - node->next = NULL; +bool list_append_value(List *list, const void *value, size_t size) { + if (list == nullptr) { + return false; + } - node->data = malloc(size); - node->data = value; - node->size = size; + Node *node = list_node_new(value, size); + if (node == nullptr) { + return false; + } - list_append(list, node); + if (!list_append(list, node)) { + list_node_destroy(node); + return false; + } + + return true; } -Node *list_pop(List *list) -{ - if (list == NULL || list->head == NULL) - { - return NULL; +Node *list_pop(List *list) { + if (list == nullptr || list->head == nullptr) { + return nullptr; } Node *node = list->head; + list->head = node->next; + list->size--; - if (list->head->next != NULL) - { - list->head = list->head->next; - list->size--; - } - else // this is the last - { - list->head = NULL; - list->tail = NULL; - list->size = 0; + // Popping the last element has to clear the tail too, or the next append + // would write through a pointer to freed memory. + if (list->head == nullptr) { + list->tail = nullptr; } - node->next = NULL; + node->next = nullptr; return node; -} \ No newline at end of file +} + +size_t list_size(const List *list) LINKEDLIST_REPRODUCIBLE { + return list == nullptr ? 0 : list->size; +} + +bool list_is_empty(const List *list) LINKEDLIST_REPRODUCIBLE { + return list_size(list) == 0; +} diff --git a/tests/main.c b/tests/main.c index 19d1b24..7f7fd3c 100644 --- a/tests/main.c +++ b/tests/main.c @@ -1,224 +1,343 @@ -#include "../include/linkedlist.h" -#include +/** + * \file main.c + * + * Test suite for the linkedlist library. + * + * The checks deliberately do not use `assert`. `assert(list_append(...))` + * looks fine until someone builds with `-DNDEBUG`, at which point the macro + * expands to nothing, the call disappears along with it, and the suite passes + * by testing nothing. \ref CHECK is a real function call that always runs. + */ + +#include "linkedlist.h" + #include #include #include -void test_list_new() -{ - List *list = list_new(); - - assert(list->size == 0); - assert(list->head == NULL); - assert(list->tail == NULL); +/** + * How many elements the bulk insertion tests push through the list. + * + * `constexpr` is new in C23. Unlike an `enum` constant it carries a real type + * (`size_t` here, so the loop counters below need no casts), and unlike + * `const size_t` it is a constant expression, usable by `static_assert`. + */ +constexpr size_t ITEM_COUNT = 10; + +static_assert(ITEM_COUNT > 0, "the bulk tests need at least one element"); + +static size_t checks_run = 0; +static size_t checks_failed = 0; + +static void check(bool ok, const char *expr, const char *file, int line) { + checks_run++; + if (!ok) { + checks_failed++; + fprintf(stderr, " FAIL %s:%d: %s\n", file, line, expr); + } +} - list_destroy(list); +/** Record one assertion. Always evaluates \p cond, in every build. */ +#define CHECK(cond) check((cond), #cond, __FILE__, __LINE__) + +/** + * Abort the run when the harness itself cannot continue. + * + * `[[noreturn]]` is the C23 spelling of what C11 wrote as `_Noreturn` and + * `` wrote as `noreturn`; both of those are deprecated now. It + * tells the compiler no code after a `die()` call is reachable, which is why + * the callers below need no `return` to keep the flow analysis happy. + */ +[[noreturn]] static void die(const char *msg) { + fprintf(stderr, "harness error: %s\n", msg); + exit(EXIT_FAILURE); } -void test_list_size_new() -{ +[[nodiscard]] static List *new_list_or_die(void) { List *list = list_new(); - - assert(list_size(list) == 0); - - list_destroy(list); + if (list == nullptr) { + die("out of memory allocating a list"); + } + return list; } -void test_prepend_to_new_list() -{ - List *list = list_new(); - Node *node = malloc(sizeof(Node)); +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- - list_prepend(list, node); +static void test_new_list_is_empty(void) { + List *list = new_list_or_die(); - assert(list_size(list) == 1); + CHECK(list->size == 0); + CHECK(list->head == nullptr); + CHECK(list->tail == nullptr); + CHECK(list_size(list) == 0); + CHECK(list_is_empty(list)); list_destroy(list); } -void test_prepend_10() -{ - List *list = list_new(); +/** + * Regression test: appending to an *empty* list used to link the node to + * itself, so a one-element list was circular and list_destroy never returned. + * Two or more appends papered over it, which is why the original suite -- it + * only ever appended ten at a time -- did not catch it. + */ +static void test_append_to_empty_list(void) { + List *list = new_list_or_die(); - for (int i = 0; i < 10; i++) - { - Node *node = malloc(sizeof(Node)); - node->next = NULL; - node->size = sizeof(int); + const int value = 42; + CHECK(list_append_value(list, &value, sizeof value)); - node->data = malloc(sizeof(Node)); - memcpy(node->data, &i, sizeof(int)); + CHECK(list_size(list) == 1); + CHECK(list->head == list->tail); + CHECK(list->head->next == nullptr); - list_prepend(list, node); - } + list_destroy(list); // Hangs forever if the self-link ever comes back. +} - assert(list_size(list) == 10); +static void test_prepend_many(void) { + List *list = new_list_or_die(); - Node *node = list->head; - for (int i = 0; i < 10; i++) - { - assert(node->size == sizeof(int)); + for (size_t i = 0; i < ITEM_COUNT; i++) { + const int value = (int)i; + CHECK(list_prepend_value(list, &value, sizeof value)); + } - int *val = (int *)node->data; - // printf("node value = %d, i= %d\n", *val, (9 - i)); - assert(*val == (9 - i)); + CHECK(list_size(list) == ITEM_COUNT); + // Prepending reverses insertion order: the last one in is at the head. + const Node *node = list->head; + for (size_t i = 0; i < ITEM_COUNT; i++) { + CHECK(node != nullptr); + CHECK(node->size == sizeof(int)); + CHECK(*(const int *)node->data == (int)(ITEM_COUNT - 1 - i)); node = node->next; } + CHECK(node == nullptr); list_destroy(list); } -void test_append_10() -{ - List *list = list_new(); +static void test_append_many(void) { + List *list = new_list_or_die(); - for (int i = 0; i < 10; i++) - { - Node *node = malloc(sizeof(Node)); - node->next = NULL; - node->size = sizeof(int); + for (size_t i = 0; i < ITEM_COUNT; i++) { + const int value = (int)i; + CHECK(list_append_value(list, &value, sizeof value)); + } - node->data = malloc(sizeof(Node)); - memcpy(node->data, &i, sizeof(int)); + CHECK(list_size(list) == ITEM_COUNT); + CHECK(list->tail->next == nullptr); - list_append(list, node); + const Node *node = list->head; + for (size_t i = 0; i < ITEM_COUNT; i++) { + CHECK(node != nullptr); + CHECK(*(const int *)node->data == (int)i); + node = node->next; } + CHECK(node == nullptr); + + list_destroy(list); +} - assert(list_size(list) == 10); +/** + * Regression test: the *_value functions used to malloc a buffer and then + * immediately overwrite the pointer with the caller's, leaking the buffer and + * silently aliasing caller-owned memory. They copy now, so mutating or + * freeing the source afterwards must not disturb the list. + */ +static void test_value_is_copied(void) { + List *list = new_list_or_die(); + + int *source = malloc(sizeof *source); + if (source == nullptr) { + die("out of memory allocating the source value"); + } + *source = 7; - Node *node = list->head; - for (int i = 0; i < 10; i++) - { - assert(node->size == sizeof(int)); + CHECK(list_append_value(list, source, sizeof *source)); - int *val = (int *)node->data; - // printf("node value = %d, i= %d\n", *val, i); - assert(*val == i); + *source = 999; + free(source); - node = node->next; - } + CHECK(*(const int *)list->head->data == 7); list_destroy(list); } -void test_list_destroy_10() -{ - List *list = list_new(); +static void test_struct_element(void) { + typedef struct { + int id; + char name[8]; + } Record; - for (int i = 0; i < 10; i++) - { - Node *node = malloc(sizeof(Node)); - node->data = NULL; - node->next = NULL; - list_prepend(list, node); - } + List *list = new_list_or_die(); + + const Record in = {.id = 3, .name = "abc"}; + CHECK(list_append_value(list, &in, sizeof in)); - assert(list_size(list) == 10); + const Record *out = list->head->data; + CHECK(list->head->size == sizeof(Record)); + CHECK(out->id == 3); + CHECK(strcmp(out->name, "abc") == 0); list_destroy(list); } -void test_list_prepend_value() -{ - List *list = list_new(); +static void test_zero_size_element(void) { + List *list = new_list_or_die(); - for (int i = 10; i > 0; i--) - { - int *val = malloc(sizeof(int)); - *val = i; - list_prepend_value(list, val, sizeof(int)); - } + // A zero-size element is the only case where a null value is accepted. + CHECK(list_append_value(list, nullptr, 0)); + CHECK(list_size(list) == 1); + CHECK(list->head->size == 0); + CHECK(list->head->data == nullptr); - assert(list_size(list) == 10); + list_destroy(list); +} - // check elemets in the list - Node *temp_node = list->head; - for (int i = 0; i < 10; i++) - { - int *val = (int *)temp_node->data; - // printf("value = %d, size = %zu (bytes)\n", *val, temp_node->size); - assert(*val == i + 1); +static void test_pop_all(void) { + List *list = new_list_or_die(); - assert(sizeof(int) == temp_node->size); - temp_node = temp_node->next; + for (size_t i = 0; i < ITEM_COUNT; i++) { + const int value = (int)i; + CHECK(list_append_value(list, &value, sizeof value)); } + for (size_t i = 0; i < ITEM_COUNT; i++) { + Node *node = list_pop(list); + CHECK(node != nullptr); + CHECK(node->next == nullptr); + CHECK(*(const int *)node->data == (int)i); + CHECK(list_size(list) == ITEM_COUNT - 1 - i); + list_node_destroy(node); + } + + CHECK(list_is_empty(list)); + CHECK(list_pop(list) == nullptr); + list_destroy(list); } -void test_list_append_value() -{ - List *list = list_new(); +/** + * Regression test: popping the last element left `tail` dangling at the freed + * node, so the next append wrote through a stale pointer. Only a sanitizer or + * valgrind run would have flagged it; the list looked fine. + */ +static void test_append_after_popping_last(void) { + List *list = new_list_or_die(); - for (int i = 10; i > 0; i--) - { - int *val = malloc(sizeof(int)); - *val = i; - list_append_value(list, val, sizeof(int)); - } + const int first = 1; + CHECK(list_append_value(list, &first, sizeof first)); - assert(list_size(list) == 10); + Node *popped = list_pop(list); + CHECK(popped != nullptr); + list_node_destroy(popped); - // check elements in the list - Node *temp_node = list->head; - for (int i = 0; i < 10; i++) - { - int *val = (int *)temp_node->data; - // printf("value = %d, size = %zu (bytes)\n", *val, temp_node->size); - assert(*val == 10 - i); + CHECK(list_is_empty(list)); + CHECK(list->tail == nullptr); - assert(sizeof(int) == temp_node->size); - temp_node = temp_node->next; - } + const int second = 2; + CHECK(list_append_value(list, &second, sizeof second)); + CHECK(list_size(list) == 1); + CHECK(*(const int *)list->head->data == 2); list_destroy(list); } -void test_list_pop_all() -{ - List *list = list_new(); +static void test_node_ownership_transfer(void) { + List *list = new_list_or_die(); - for (int i = 0; i < 10; i++) - { - Node *node = malloc(sizeof(Node)); - node->data = malloc(sizeof(int)); - memcpy(node->data, &i, sizeof(int)); - node->next = NULL; - node->size = sizeof(int); - - list_prepend(list, node); + const int value = 5; + Node *node = list_node_new(&value, sizeof value); + if (node == nullptr) { + die("out of memory allocating a node"); } - assert(list_size(list) == 10); + // From here the list owns the node; list_destroy frees it. + CHECK(list_prepend(list, node)); + CHECK(list_size(list) == 1); + + list_destroy(list); +} - for (int i = 0; i < 10; i++) - { - Node *pop_node = list_pop(list); - assert(pop_node != NULL); - // int *val = (int *)pop_node->data; - // printf("value: %d, size: %zu\n", *val, pop_node->size); +static void test_null_arguments_are_rejected(void) { + const int value = 1; + + CHECK(!list_append(nullptr, nullptr)); + CHECK(!list_prepend(nullptr, nullptr)); + CHECK(!list_append_value(nullptr, &value, sizeof value)); + CHECK(!list_prepend_value(nullptr, &value, sizeof value)); + CHECK(list_pop(nullptr) == nullptr); + CHECK(list_size(nullptr) == 0); + CHECK(list_is_empty(nullptr)); + CHECK(list_node_new(nullptr, sizeof(int)) == nullptr); + + // Both of these are documented no-ops, so this only has to not crash. + list_destroy(nullptr); + list_node_destroy(nullptr); +} - list_node_destroy(pop_node); - } +static void test_append_literal_macro(void) { + List *list = new_list_or_die(); + + // typeof + a compound literal: no temporary variable, no sizeof by hand. + CHECK(LIST_APPEND_LITERAL(list, 42)); + CHECK(LIST_APPEND_LITERAL(list, 2.5)); + + CHECK(list_size(list) == 2); + CHECK(list->head->size == sizeof(int)); + CHECK(*(const int *)list->head->data == 42); + CHECK(list->tail->size == sizeof(double)); + CHECK(*(const double *)list->tail->data == 2.5); list_destroy(list); } -void tests_run_all(void) -{ - test_list_new(); - test_list_size_new(); - test_prepend_to_new_list(); - test_prepend_10(); - test_append_10(); - test_list_destroy_10(); - test_list_prepend_value(); - test_list_append_value(); - test_list_pop_all(); -} +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +typedef struct { + const char *name; + void (*run)(void); +} TestCase; + +#define TEST_CASE(fn) {.name = #fn, .run = fn} + +int main(void) { + static const TestCase tests[] = { + TEST_CASE(test_new_list_is_empty), + TEST_CASE(test_append_to_empty_list), + TEST_CASE(test_prepend_many), + TEST_CASE(test_append_many), + TEST_CASE(test_value_is_copied), + TEST_CASE(test_struct_element), + TEST_CASE(test_zero_size_element), + TEST_CASE(test_pop_all), + TEST_CASE(test_append_after_popping_last), + TEST_CASE(test_node_ownership_transfer), + TEST_CASE(test_null_arguments_are_rejected), + TEST_CASE(test_append_literal_macro), + }; + + constexpr size_t test_count = sizeof tests / sizeof tests[0]; + + for (size_t i = 0; i < test_count; i++) { + const size_t before = checks_failed; + tests[i].run(); + printf(" %-4s %s\n", checks_failed == before ? "ok" : "FAIL", tests[i].name); + } + + printf( + "\n%zu test%s, %zu check%s, %zu failed\n", + test_count, + test_count == 1 ? "" : "s", + checks_run, + checks_run == 1 ? "" : "s", + checks_failed + ); -int main(void) -{ - tests_run_all(); -} \ No newline at end of file + return checks_failed == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} From 47a7ae2c0def06270c6a076d2e2733d948442f1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 9 Aug 2026 15:06:50 +0200 Subject: [PATCH 2/5] refactor: use #pragma once in the public header Every compiler this template targets supports it, it cannot collide with another project's macro, and it drops the trailing #endif that has to stay in sync with a name 250 lines above it. Matches the sdl3/codecamp header style. --- include/linkedlist.h | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/include/linkedlist.h b/include/linkedlist.h index efb0a46..9ccb2d7 100644 --- a/include/linkedlist.h +++ b/include/linkedlist.h @@ -1,5 +1,4 @@ -#ifndef LINKEDLIST_H -#define LINKEDLIST_H +#pragma once /** * \file linkedlist.h @@ -259,5 +258,3 @@ void list_node_destroy(Node *node); */ #define LIST_APPEND_LITERAL(list, expr) \ list_append_value((list), &(typeof(expr)){(expr)}, sizeof(typeof(expr))) - -#endif // LINKEDLIST_H From 514ac50dc3c26dc72d74d35fe44604e3f8dd9c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 9 Aug 2026 15:19:35 +0200 Subject: [PATCH 3/5] refactor!: make List and Node opaque, add the read API they require BREAKING CHANGE: every caller must be updated. The structs are no longer defined in the header, and the insertion functions take values rather than caller-allocated nodes. Why --- With both structs public, three things were expressible from outside the library, all verified against the previous build: - list_append(l, n) twice was accepted. The count said 2, there was one node, and its next pointed at itself; destroying that list never terminated. Exactly the bug that was just fixed inside list_append, still reachable from the caller side. - A hand-rolled malloc(sizeof(Node)) was accepted with an uninitialised payload pointer, which teardown then handed to free. - Reading anything meant list->head->data, so the struct layout was part of the ABI and the read path was untestable as an API. All three are now compile errors: "invalid use of incomplete typedef". API --- Insertion no longer deals in nodes, so the _value suffix is gone: list_append(list, &value, sizeof value) list_prepend(list, &value, sizeof value) list_node_new and list_node_destroy are gone with it -- nothing outside the library allocates a node any more, which is what removes the ownership question entirely. Nothing in the API transfers ownership in either direction. Hiding the layout forced the read API that was missing all along: list_at / list_at_mut borrow element N, with its size list_first / node_next cursor iteration, O(n) for a full walk node_value / node_size read through a cursor list_pop(list, out, size) copy the head out and remove it list_drop / list_clear remove without copying list_pop checks that size matches the stored element and refuses on mismatch rather than handing back a partial read, leaving the element in place. Zero-size elements are now rejected. They came back from list_at as a non-null pointer to nothing, indistinguishable from a real element, which made every borrow-returning function ambiguous. Implementation -------------- - Each element's bytes now live in the same allocation as its node, via a flexible array member. Halves the malloc/free traffic and puts the payload in the same cache line as the next pointer. No caller had to change, which is the point of the opacity. - alignas(max_align_t) on that member is load-bearing: a flexible array of unsigned char is only byte-aligned, but the payload may be a double. test_alignment_of_payload pins it down and UBSan would catch a regression. - ckd_add from C23's guards sizeof(Node) + size. A wrapped total would allocate a few bytes and then memcpy gigabytes into them. - List::size renamed to List::count. A node's size is a byte count and a list's was an element count, one screen apart in the same header. - unlink_head and node_at factor the bookkeeping that pop/drop and at/at_mut respectively used to duplicate. node_at returns a mutable Node* from a const List* with no cast: constness of a struct does not propagate to what its members point at. Makefile -------- `make CFLAGS=-O0` silently dropped -std=c23, -Iinclude and every warning, because a command-line variable suppresses plain assignments to it in the makefile. The project's flags are appended with `override` now, so a caller's CFLAGS tunes the build instead of replacing it. Tests ----- Rewritten against the public API only -- enforced, not aspirational, since list->head no longer compiles. 17 tests, 220 checks. New coverage for payload alignment, in-place mutation, out-of-range indices, pop with a mismatched size, clear-then-reuse, mixed element types, the ckd_add overflow path, and single evaluation of the LIST_APPEND_LITERAL argument. Docs ---- README rewritten as open-source documentation: badges, a full Makefile reference (every target, every overridable variable, worked recipes), the C23 feature table, an API reference, and an honest caveats section. The example compiles clean under -Werror. docs/C23.md gains the alignas and ckd_add sections and drops the claim that neither was needed. Verified on gcc-16 and clang, plain and under ASan+UBSan, 0 leaks under leaks. --- Makefile | 22 +- README.md | 662 +++++++++++++++++++++++++++++++++++-------- docs/C23.md | 57 +++- include/linkedlist.h | 289 ++++++++++++------- src/linkedlist.c | 338 +++++++++++++++------- tests/main.c | 359 +++++++++++++++++------ 6 files changed, 1300 insertions(+), 427 deletions(-) diff --git a/Makefile b/Makefile index abb1524..a6021a5 100644 --- a/Makefile +++ b/Makefile @@ -120,21 +120,29 @@ FORMAT_FILES := $(SRCS) $(TEST_SRCS) $(wildcard $(INC_DIR)/*.h) # and using one set of objects for both keeps the build simple # -MMD -MP header dependency tracking (-MP adds dummy rules so deleting a # header doesn't break the build with "no rule to make target") +# CFLAGS is yours to set -- `make CFLAGS="-O0 -g3"` swaps the optimisation +# level and nothing else. CFLAGS ?= -O2 -g -CFLAGS += -std=c23 -Wall -Wextra -Werror -Wpedantic \ - -Wshadow -Wconversion -Wstrict-prototypes -Wwrite-strings \ - -Wcast-qual -Wundef -Wvla \ - -fPIC -MMD -MP -I$(INC_DIR) + +# `override` is load-bearing. A variable set on the command line normally wins +# outright and every plain assignment to it in the makefile is ignored, so a +# bare `CFLAGS +=` here would silently vanish under `make CFLAGS=-O0` -- taking +# -std=c23, -I$(INC_DIR) and the whole warning set with it. `override` is what +# lets the project's non-negotiable flags survive alongside the user's. +override CFLAGS += -std=c23 -Wall -Wextra -Werror -Wpedantic \ + -Wshadow -Wconversion -Wstrict-prototypes -Wwrite-strings \ + -Wcast-qual -Wundef -Wvla \ + -fPIC -MMD -MP -I$(INC_DIR) LDFLAGS ?= LDLIBS ?= ifeq ($(SANITIZE),1) # Both sanitizers instrument at compile time and need the same flags at link - # time, which is why they go in CFLAGS and LDFLAGS. + # time, which is why they go in CFLAGS and LDFLAGS. Same `override` reason. SAN_FLAGS := -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all - CFLAGS += $(SAN_FLAGS) - LDFLAGS += $(SAN_FLAGS) + override CFLAGS += $(SAN_FLAGS) + override LDFLAGS += $(SAN_FLAGS) endif # Install locations, used by `make install`. diff --git a/README.md b/README.md index c8a2241..beecb64 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,295 @@ -# c-library-template - -A starting point for a C library written against **C23**, with the build, -editor, and CI wiring already done. The linked list it ships with is a working -example, not the point — replace it. - -- Static (`.a`) and shared (`.dylib` / `.so`) builds from one set of objects -- `-std=c23` with a warning set that includes `-Werror -Wconversion -Wshadow` -- Header dependency tracking, so editing a header rebuilds exactly what used it -- Test suite that does not disappear under `-DNDEBUG` -- AddressSanitizer + UndefinedBehaviorSanitizer, plus `leaks` / `valgrind` -- clangd-based IntelliSense that actually understands C23 attributes -- `clang-format`, Doxygen, and a GitHub Actions matrix across GCC, Clang, and - Apple Clang - -## Requirements - -| Tool | Version | Needed for | -| ------------ | ------- | ----------------------- | -| GCC | ≥ 14 | `-std=c23` | -| or Clang | ≥ 18 | `-std=c23` | -| GNU Make | ≥ 3.81 | the build | -| clang-format | any | `make format` | -| Doxygen | any | `make docs` | -| valgrind | any | `make memcheck` (Linux) | - -macOS: +# 🧩 c-library-template + +[![C CI](https://github.com/slashdevops/c-library-template/actions/workflows/c-build.yml/badge.svg)](https://github.com/slashdevops/c-library-template/actions/workflows/c-build.yml) +[![Standard: C23](https://img.shields.io/badge/standard-C23-blue.svg)](https://en.cppreference.com/w/c/23) +[![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-green.svg)](LICENSE) +[![Platforms](https://img.shields.io/badge/platforms-macOS%20%7C%20Linux-lightgrey.svg)](#-requirements) + +> 🚀 A batteries-included starting point for a **C23** library: build, tests, +> sanitizers, formatting, docs, editor, and CI already wired up. + +The singly linked list it ships with is a **worked example, not the point**. +Rip it out and put your own library in its place — the scaffolding is what you +are cloning this for. + +--- + +## 📑 Table of contents + +- [✨ What you get](#-what-you-get) +- [🆕 Why C23](#-why-c23) +- [📋 Requirements](#-requirements) +- [⚡ Quick start](#-quick-start) +- [🧰 The Makefile, in full](#-the-makefile-in-full) +- [📚 Using the library](#-using-the-library) +- [🗂️ Project layout](#️-project-layout) +- [🧑‍💻 Editor setup](#-editor-setup) +- [🧪 Testing, sanitizers, and leaks](#-testing-sanitizers-and-leaks) +- [🎨 Formatting](#-formatting) +- [📖 API documentation](#-api-documentation) +- [🤖 Continuous integration](#-continuous-integration) +- [🛠️ Making it your own](#️-making-it-your-own) +- [⚠️ Honest caveats](#️-honest-caveats) +- [🤝 Contributing](#-contributing) +- [📄 License](#-license) + +--- + +## ✨ What you get + +| | | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| 🎯 **Real C23** | `-std=c23`, not the `-std=c2x` draft spelling. `nullptr`, `constexpr`, `typeof`, `alignas`, `[[attributes]]`, `` | +| 📦 **Two library flavours** | Static `.a` and shared `.dylib`/`.so` from one set of `-fPIC` objects | +| 🔒 **Opaque types** | `List` and `Node` are incomplete outside the implementation, so callers physically cannot corrupt them | +| 🧪 **Tests that can't lie** | No bare `assert` — the harness survives `-DNDEBUG` | +| 🧹 **Sanitizers** | `make sanitize` for ASan + UBSan, into its own build tree | +| 💧 **Leak checks** | `make memcheck` → `leaks` on macOS, `valgrind` on Linux | +| 🧠 **Smart rebuilds** | `-MMD -MP` header dependency tracking — touch a header, rebuild exactly what used it | +| 🖊️ **One formatting truth** | `.clang-format`, enforced by CI | +| 📖 **Docs** | Doxygen, generated into `build/docs` | +| 🧑‍💻 **Editor that understands C23** | clangd-driven IntelliSense, because cpptools' engine cannot parse C23 attributes | +| 🤖 **CI across three compilers** | GCC 14, Clang 18, and Apple Clang | +| 📥 **Installable** | `make install` honouring `PREFIX` and `DESTDIR` | + +--- + +## 🆕 Why C23 + +This template exists to be **genuinely C23**, not C17 with a newer flag. Here is +what that actually means in the code you are about to read. + +### 🔑 The language features, and where they earn their place + +| Feature | What changed | Where it is used | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------- | +| 🕳️ `nullptr` | A real typed null constant. Unlike `NULL` it never silently becomes an `int` in a variadic call | Everywhere | +| ✅ `bool` / `true` / `false` | Now keywords — **no ``** | Everywhere | +| 📐 `static_assert` | Now a keyword — **no ``**. The message is optional | `tests/main.c` | +| 🧊 `constexpr` | A named constant with a **real type**, usable in constant expressions (an `enum` gives you `int`; a `const size_t` is not a constant expression) | `ITEM_COUNT` in `tests/main.c` | +| 🔍 `typeof` | A GNU extension for 30 years, now standard — so it survives `-Wpedantic` | `LIST_APPEND_LITERAL` | +| 📏 `alignas` | Now a keyword — **no ``** | `struct Node`'s flexible array member | +| ➕ `` | `ckd_add` / `ckd_sub` / `ckd_mul` report integer overflow instead of producing it | Guarding the allocation size | +| 🏷️ `[[attributes]]` | The C++ bracket syntax, with seven standard attributes | See below | +| 🚫 K&R declarations | **Removed.** An empty `()` now means `(void)` | All prototypes | + +### 🏷️ Attributes: the part people get wrong + +C23 defines seven standard attributes. Five behave the way you would expect +from C++. The other two do not: + +```c +[[nodiscard]] size_t list_size(const List *list) [[reproducible]]; // ✅ correct +[[reproducible]] size_t list_size(const List *list); // ❌ wrong +``` + +`[[reproducible]]` and `[[unsequenced]]` attach to the **function type**, not +the declaration, so they go **after** the parameter list. GCC rejects the +leading form outright. + +They are also the two attributes **Clang does not implement yet**, which is why +the header feature-detects them: + +```c +#if defined(__has_c_attribute) +# if __has_c_attribute(reproducible) +# define LINKEDLIST_REPRODUCIBLE [[reproducible]] +# endif +#endif +``` + +> ⚠️ **Test for non-zero, never `>= 202311L`.** `__has_c_attribute` returns the +> attribute's standardisation date, and Clang reports the *C++* paper dates — +> `nodiscard` comes back as `202003`. A `>= 202311L` check would disable +> `[[nodiscard]]` on a compiler that supports it perfectly well. + +📘 **[docs/C23.md](docs/C23.md)** has the full treatment: every attribute, the +compiler support matrix measured on real toolchains, the `reproducible` vs +`unsequenced` distinction, and the features deliberately left out. + +### 🧯 The C23 guard + +[include/linkedlist.h](include/linkedlist.h) refuses to compile under an older +standard, rather than burying you in syntax errors on `nullptr`: + +```c +#if !defined(__STDC_VERSION__) || __STDC_VERSION__ < 202311L +#error "linkedlist.h requires C23. Compile with -std=c23 (GCC >= 14, Clang >= 18)." +#endif +``` + +--- + +## 📋 Requirements + +| Tool | Version | Needed for | +| ----------------- | -------- | ---------------------------------------------------------- | +| 🔨 **GCC** | **≥ 14** | `-std=c23` | +| 🔨 *or* **Clang** | **≥ 18** | `-std=c23` | +| 🐚 GNU Make | ≥ 3.81 | the build | +| 🖊️ clang-format | any | `make format` | +| 📖 Doxygen | any | `make docs` | +| 💧 valgrind | any | `make memcheck` on Linux (macOS uses the built-in `leaks`) | + +> 💡 Only GCC currently implements `[[reproducible]]` and `[[unsequenced]]`. +> Everything still compiles on Clang — the header feature-detects them. + +**macOS** 🍎 ```bash brew install gcc clang-format doxygen ``` -Debian / Ubuntu: +**Debian / Ubuntu** 🐧 ```bash sudo apt-get install gcc-14 clang-format doxygen valgrind ``` -The Makefile picks the newest `gcc-NN` in your Homebrew prefix on macOS and -`gcc` on Linux. Override it whenever you like: +--- + +## ⚡ Quick start ```bash -make CC=clang test +git clone https://github.com/slashdevops/c-library-template.git +cd c-library-template + +make # 📦 build the static + shared libraries +make test # 🧪 build and run the test suite +make help # 📋 list every target ``` -## Targets +Expected output from `make test`: -```bash -make help -``` - -| Target | What it does | -| ------------------- | -------------------------------------------------------------------- | -| `make` | Build the static and shared libraries | -| `make static` | Static library only | -| `make shared` | Shared library only | -| `make test` | Build and run the test suite | -| `make sanitize` | Run the tests under ASan + UBSan (separate `build/sanitize/` tree) | -| `make memcheck` | Run the tests under `leaks` (macOS) or `valgrind` (Linux) | -| `make format` | Rewrite sources with `.clang-format` | -| `make format-check` | Fail if anything is unformatted — what CI runs | -| `make docs` | Generate HTML API docs into `build/docs/html` | -| `make docs-open` | Build the docs and open them | -| `make install` | Install headers and libraries under `$PREFIX` (default `/usr/local`) | -| `make clean` | Delete `build/` | - -`make install` honours `PREFIX` and `DESTDIR`: +```text +Running tests with gcc-16... + ok test_new_list_is_empty + ok test_append_to_empty_list + ... +17 tests, 220 checks, 0 failed +``` + +--- + +## 🧰 The Makefile, in full + +Everything goes through the Makefile — the editor tasks and CI both shell out +to it, so there is exactly one place where `-std=c23` is defined and nothing +can drift. + +### 🎯 Targets + +Run `make help` for this list at any time. + +| Target | Emoji | What it does | +| ------------------- | :---: | ----------------------------------------------------------------- | +| `make` / `make all` | 📦 | Build the static and shared libraries | +| `make build` | 📦 | Same thing, spelled out | +| `make static` | 🧱 | Static library only → `build/lib/liblinkedlist.a` | +| `make shared` | 🔗 | Shared library only → `build/lib/liblinkedlist.{dylib,so}` | +| `make test` | 🧪 | Build and run the test suite | +| `make sanitize` | 🧹 | Run the tests under AddressSanitizer + UndefinedBehaviorSanitizer | +| `make memcheck` | 💧 | Run the tests under `leaks` (macOS) or `valgrind` (Linux) | +| `make format` | 🖊️ | Rewrite every source in place using `.clang-format` | +| `make format-check` | 🔍 | Fail if anything is unformatted — this is what CI runs | +| `make docs` | 📖 | Generate HTML API docs into `build/docs/html` | +| `make docs-open` | 🌐 | Generate the docs and open them in a browser | +| `make install` | 📥 | Install headers and libraries under `$PREFIX` | +| `make uninstall` | 🗑️ | Undo `make install` | +| `make clean` | 🧽 | Delete `build/` | +| `make help` | 📋 | Print the target list | + +### ⚙️ Variables you can override + +All of these work as `make VAR=value target`. + +| Variable | Default | Purpose | +| ------------ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `CC` | newest Homebrew `gcc-NN`, else `cc` (macOS) / `gcc` (Linux) | The compiler | +| `CFLAGS` | `-O2 -g` | **Your** flags. The project's own (`-std=c23`, warnings, `-I`) are appended with `override` and always survive | +| `LDFLAGS` | empty | Extra linker flags | +| `LDLIBS` | empty | Extra libraries to link | +| `AR` | `ar` | Archiver for the static library | +| `SANITIZE` | unset | `SANITIZE=1` enables ASan + UBSan and redirects the build to `build/sanitize/` | +| `PREFIX` | `/usr/local` | Install root | +| `DESTDIR` | empty | Staging root, prepended to every install path (for packagers) | +| `INCLUDEDIR` | `$(PREFIX)/include` | Where the header goes | +| `LIBDIR` | `$(PREFIX)/lib` | Where the libraries go | +| `FORMAT` | `clang-format` | Formatter binary — handy for `clang-format-18` on CI | +| `DOXYGEN` | `doxygen` | Doc generator binary | +| `MEMCHECK` | `leaks` / `valgrind` | Memory checker | + +### 💡 Recipes ```bash +# 🔄 Build with a different compiler +make CC=clang test +make CC=gcc-14 test + +# 🐞 Unoptimised debug build (project flags are preserved) +make CFLAGS="-O0 -g3" test + +# ⚡ Parallel build +make -j$(sysctl -n hw.ncpu) # macOS +make -j$(nproc) # Linux + +# 📥 Install into your home directory make install PREFIX="$HOME/.local" + +# 📦 Stage into a package root make install DESTDIR=/tmp/stage PREFIX=/usr + +# 🧹 Sanitizer run (does not disturb the normal build) +make sanitize ``` -## Layout +> ⚠️ `make CFLAGS=…` used to silently drop `-std=c23` and `-Iinclude`, because a +> command-line variable normally suppresses every plain assignment to it in the +> makefile. The project flags are appended with `override` specifically to +> prevent that. It is a trap worth knowing about in any Makefile you write. + +### 🏗️ How the build tree is laid out ```text -include/linkedlist.h public header — the only file consumers need -src/linkedlist.c implementation -tests/main.c test suite and its runner -docs/C23.md which C23 features are used, and why -compile_flags.txt flags clangd applies to every file -.clang-format formatting rules, enforced by CI -Doxyfile API doc configuration -build/ everything generated; git-ignored, safe to delete +build/ +├── obj/ +│ ├── linkedlist.o library objects (-fPIC) +│ ├── linkedlist.d generated header dependencies +│ └── tests/main.o +├── lib/ +│ ├── liblinkedlist.a static +│ └── liblinkedlist.dylib shared (.so on Linux) +├── test_linkedlist the test binary +├── docs/html/ Doxygen output +└── sanitize/ a complete parallel tree for SANITIZE=1 ``` -Everything the build produces lives under `build/` — objects, `.d` dependency -files, both libraries, the test binary, the sanitizer tree, and the docs. There -are no stray `obj/` or `lib/` directories at the repo root. +Everything generated lives under `build/`, so `.gitignore` needs one entry and +`make clean` is a single `rm -rf`. -## Editor setup +**Sanitizer builds get their own tree** because they compile with different +flags. Sharing one directory would leave `make test` and `make sanitize` +trading the same `.o` files back and forth, each silently reusing the other's +objects. -Install the recommended extensions when VS Code offers them -([.vscode/extensions.json](.vscode/extensions.json)); the important one is -**clangd**. +### 🧠 Dependency tracking -IntelliSense is clangd's job here and cpptools' engine is switched off. That is -not a preference — cpptools, and especially its Tag Parser fallback, does not -parse `nullptr`, `constexpr`, or `[[nodiscard]]` in C, so it marks correct C23 -code as broken. cpptools is still installed because its debugger is the one -`launch.json` uses. +`-MMD -MP` writes a `.d` file next to every object listing the headers it +included, and the Makefile pulls them in with `-include`. Touch +`include/linkedlist.h` and exactly the objects that included it get rebuilt — +no `make clean` reflex required. `-MP` adds dummy rules so that *deleting* a +header does not break the build with "no rule to make target". -clangd reads [compile_flags.txt](compile_flags.txt), so it needs no build -integration. Keep `-std=c23` in that file in sync with `CFLAGS` in the Makefile. -If the project later grows per-file flags, generate a real -`compile_commands.json` with `bear -- make` and delete `compile_flags.txt` — -clangd prefers the former when both exist. +### ➕ Adding source files + +Just create them. `src/*.c` and `tests/*.c` are globbed, so a new file is +picked up with no Makefile change. + +--- -`F5` debugs the test binary. `Cmd/Ctrl+Shift+B` builds. +## 📚 Using the library -## Using the library +### 🎬 A complete example ```c #include @@ -120,80 +301,309 @@ int main(void) { return 1; } + // ➕ Values are COPIED in. `value` can go out of scope immediately. + // Every fallible call is [[nodiscard]], so the compiler makes you look. const int value = 42; - if (!list_append_value(list, &value, sizeof value)) { + if (!list_append(list, &value, sizeof value)) { list_destroy(list); return 1; } - // Or skip the temporary entirely — typeof works out the size: - LIST_APPEND_LITERAL(list, 3.5); + // 🪄 Or skip the temporary entirely — typeof works the size out. + if (!LIST_APPEND_LITERAL(list, 3.5)) { + list_destroy(list); + return 1; + } - printf("%zu elements\n", list_size(list)); + // 🔁 Walk it with a cursor. + for (const Node *n = list_first(list); n != nullptr; n = node_next(n)) { + printf("element of %zu bytes\n", node_size(n)); + } + + // 🎯 Or index into it. + size_t size = 0; + const int *first = list_at(list, 0, &size); + printf("first = %d (%zu bytes)\n", *first, size); + + // 📤 Pop copies the element out and removes it. + int popped = 0; + if (list_pop(list, &popped, sizeof popped)) { + printf("popped %d\n", popped); + } - list_destroy(list); // Frees every node and payload. + list_destroy(list); // 🧹 frees the list and everything still in it } ``` +Compile against it: + ```bash +# static cc -std=c23 -Iinclude example.c build/lib/liblinkedlist.a -o example + +# or after `make install PREFIX=$HOME/.local` +cc -std=c23 -I"$HOME/.local/include" example.c -L"$HOME/.local/lib" -llinkedlist -o example ``` -Two ownership rules cover the whole API: +### 📖 API at a glance -- `list_append_value` / `list_prepend_value` **copy** the bytes you hand them. - You keep the original and may free it immediately. -- `list_append` / `list_prepend` **take ownership** of a `Node *` you allocated - with `list_node_new`. `list_destroy` frees it. +**Lifecycle** 🔄 -Nodes that come back out of `list_pop` belong to you again — pass them to -`list_node_destroy` or insert them into another list. +| Function | Description | +| --------------------------- | -------------------------------------------------------- | +| `List *list_new(void)` | Allocate an empty list, or `nullptr` on failure | +| `void list_destroy(List *)` | Free the list and everything in it. `nullptr` is a no-op | +| `void list_clear(List *)` | Empty it but keep it usable | -## Making it your own +**Insertion** ➕ — both **copy** `size` bytes out of `value` -1. Rename `LIB_NAME` in the [Makefile](Makefile) — the archive, shared library, - soname, and install paths all follow from it. -2. Replace `include/linkedlist.h`, `src/linkedlist.c`, and `tests/main.c`. -3. Update `PROJECT_NAME` and `PROJECT_BRIEF` in the [Doxyfile](Doxyfile). -4. Point `program` in [.vscode/launch.json](.vscode/launch.json) at the new test - binary name. -5. Adjust `compilerPath` in - [.vscode/c_cpp_properties.json](.vscode/c_cpp_properties.json) to a compiler - you actually have. +| Function | Description | +| ----------------------------------------------------------- | -------------------------------------------- | +| `bool list_append(List *, const void *value, size_t size)` | Add to the back, O(1) | +| `bool list_prepend(List *, const void *value, size_t size)` | Add to the front, O(1) | +| `LIST_APPEND_LITERAL(list, expr)` | Append an expression without naming its type | + +**Queries** 🔍 + +| Function | Description | +| --------------------------------------------------------------- | ------------------------------------ | +| `size_t list_size(const List *)` | Element count | +| `bool list_is_empty(const List *)` | Count is zero | +| `const void *list_at(const List *, size_t index, size_t *size)` | Borrow element `index`, or `nullptr` | +| `void *list_at_mut(List *, size_t index, size_t *size)` | Same, writable | + +**Iteration** 🔁 + +| Function | Description | +| -------------------------------------- | -------------------------------- | +| `const Node *list_first(const List *)` | Cursor to the first element | +| `const Node *node_next(const Node *)` | Advance, or `nullptr` at the end | +| `const void *node_value(const Node *)` | The element's bytes | +| `size_t node_size(const Node *)` | The element's size | -Adding a `.c` file to `src/` or `tests/` needs no Makefile change — both -directories are globbed. +**Removal** ➖ -### Before shipping a real shared library +| Function | Description | +| ----------------------------------------------- | ---------------------------------------- | +| `bool list_pop(List *, void *out, size_t size)` | Copy the first element out and remove it | +| `bool list_drop(List *)` | Remove the first element without copying | -The shared library here is unversioned: it records a soname of -`liblinkedlist.so` (or an install name of `@rpath/liblinkedlist.dylib`). That is -fine for a template and for static linking. A library you actually distribute -needs a version in the soname (`liblinkedlist.so.1`) plus the usual symlink -chain, so consumers keep working when you break ABI. +### 📜 The rules -## C23 notes +1. 📋 **Everything is copied.** `list_append` and `list_prepend` read `size` + bytes out of your value. You keep the original and may free it immediately. + No function in this API transfers ownership in either direction. +2. 🧹 **One thing to remember:** call `list_destroy`. +3. ⏳ **Borrowed pointers and cursors are invalidated** by any insertion or + removal. Use `list_pop` if you need the bytes to outlive the list. +4. 🔬 **`list_pop` checks the size.** A mismatch means the wrong type, so the + call fails and the element stays put rather than handing you a partial read. +5. 🚫 **Zero-size elements are rejected.** They would come back from `list_at` + as a non-null pointer to nothing, indistinguishable from a real element. -[docs/C23.md](docs/C23.md) covers which C23 features this template uses, the -placement rule that makes `[[reproducible]]` and `[[unsequenced]]` different -from every other attribute, how `__has_c_attribute` keeps the header compiling -on Clang, and which features were deliberately left out. +### 🔒 Why `List` and `Node` are opaque -## CI +You cannot dereference a `List *`, take its `sizeof`, or allocate one yourself. +That is deliberate. An earlier version of this header defined both structs in +full, and three things followed: + +| ❌ Was possible | ✅ Now | +| ------------------------------------------------------------------------------------------------------------------------ | ------------- | +| Appending the same node twice — count said 2, one node pointed at itself, teardown never terminated | Won't compile | +| Handing over a hand-rolled `malloc(sizeof(Node))` with an uninitialised payload pointer, which teardown passed to `free` | Won't compile | +| Reading via `list->head->data`, making the struct layout part of the ABI | Won't compile | + +Hiding the layout also paid for itself: each element's bytes now live in the +**same allocation** as its node (a flexible array member), halving the calls to +`malloc` and `free` and putting the payload in the same cache line as the +`next` pointer that led to it. **Not one caller had to change.** + +--- + +## 🗂️ Project layout + +```text +├── 📄 include/linkedlist.h public header — the only file consumers need +├── 🔧 src/linkedlist.c implementation, incl. the hidden struct definitions +├── 🧪 tests/main.c test suite and its runner +├── 📘 docs/C23.md which C23 features are used, and why +├── 🧰 Makefile the whole build +├── 🖊️ .clang-format formatting rules, enforced by CI +├── 🧑‍💻 compile_flags.txt flags clangd applies to every file +├── 📖 Doxyfile API doc configuration +├── ⚙️ .editorconfig editor-agnostic whitespace baseline +├── 🆚 .vscode/ tasks, launch configs, clangd settings +├── 🤖 .github/workflows/ CI +└── 🏗️ build/ everything generated; gitignored, safe to delete +``` + +--- + +## 🧑‍💻 Editor setup + +Accept the recommended extensions when VS Code offers them +([.vscode/extensions.json](.vscode/extensions.json)). The important one is +**clangd**. + +> 🚨 **IntelliSense is clangd's job here, and the cpptools engine is switched +> off.** That is not a preference. cpptools — and especially its Tag Parser +> fallback — does not parse `nullptr`, `constexpr`, or `[[nodiscard]]` in C, so +> it marks correct C23 code as broken. cpptools stays installed because its +> debugger is what `launch.json` uses. + +clangd reads [compile_flags.txt](compile_flags.txt), so it needs no build +integration. **Keep `-std=c23` in that file in sync with the Makefile.** If the +project grows per-file flags, generate a real `compile_commands.json` with +`bear -- make` and delete `compile_flags.txt` — clangd prefers the former when +both exist. + +| Shortcut | Action | +| ------------------------------- | --------------------------------------------------- | +| `Cmd/Ctrl+Shift+B` | Build | +| `F5` | Debug the test binary (lldb on macOS, gdb on Linux) | +| `Cmd/Ctrl+Shift+P` → *Run Task* | Any Makefile target | + +There is also a **Debug sanitizer build** launch configuration, which runs +`make sanitize` first and stops on the exact line ASan or UBSan reports. + +--- + +## 🧪 Testing, sanitizers, and leaks + +```bash +make test # 🧪 plain run +make sanitize # 🧹 ASan + UBSan +make memcheck # 💧 leaks (macOS) / valgrind (Linux) +``` + +### 🚫 Why the tests avoid `assert` + +```c +assert(list_append(list, &value, sizeof value)); // ☠️ silently vanishes under -DNDEBUG +``` + +`assert` expands to nothing when `NDEBUG` is defined, taking the **call itself** +with it. The suite would pass while testing nothing. The `CHECK` macro here is a +real function call that runs in every build, counts assertions, and reports a +count you can sanity-check: + +```text +17 tests, 220 checks, 0 failed +``` + +> 💧 valgrind and ASan both hook the allocator and refuse to share a process, so +> `make memcheck` deliberately runs against a **plain** build, not a sanitizer +> one. + +--- + +## 🎨 Formatting + +```bash +make format # 🖊️ rewrite everything in place +make format-check # 🔍 fail if anything is unformatted (CI runs this) +``` + +Rules live in [.clang-format](.clang-format) — LLVM style, 2-space indent, +100-column limit. VS Code formats on save via clangd. + +--- + +## 📖 API documentation + +Every public declaration carries Doxygen comments. + +```bash +brew install doxygen # once +make docs # 📖 generate build/docs/html +make docs-open # 🌐 generate and open +``` + +This README becomes the landing page. Output goes under `build/`, so it is +never committed, and CI uploads it as an artifact on every run. + +--- + +## 🤖 Continuous integration [.github/workflows/c-build.yml](.github/workflows/c-build.yml) runs on every push and pull request: -- **format** — `clang-format` diff check -- **test** — build, test, and sanitize on Ubuntu with GCC 14 and Clang 18, and - on macOS with Apple Clang; then a staged `make install` / `make uninstall` -- **memcheck** — valgrind against a non-sanitizer build (the two cannot share a - process) -- **docs** — Doxygen build, uploaded as an artifact +| Job | What it proves | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| 🖊️ **format** | `clang-format` is happy | +| 🧪 **test** | Builds, tests, and sanitizes on Ubuntu (GCC 14, Clang 18) and macOS (Apple Clang), then does a staged `make install` / `make uninstall` | +| 💧 **memcheck** | valgrind against a non-sanitizer build | +| 📖 **docs** | Doxygen builds, uploaded as an artifact | + +> 🍎 The macOS leg is not redundant. Apple Clang has C23 but **not** +> `[[reproducible]]` — it is the job that proves the `__has_c_attribute` +> fallback in the header actually works. + +--- + +## 🛠️ Making it your own + +1. 🏷️ Rename `LIB_NAME` in the [Makefile](Makefile). The archive, shared + library, soname, and install paths all follow from it. +2. ✍️ Replace `include/linkedlist.h`, `src/linkedlist.c`, and `tests/main.c`. +3. 📖 Update `PROJECT_NAME` and `PROJECT_BRIEF` in the [Doxyfile](Doxyfile). +4. 🐞 Point `program` in [.vscode/launch.json](.vscode/launch.json) at your new + test binary name. +5. 🔨 Adjust `compilerPath` in + [.vscode/c_cpp_properties.json](.vscode/c_cpp_properties.json) to a compiler + you actually have. +6. 🔤 Consider prefixing your public names. `List` and `Node` are fine for a + template but far too generic to drop into a consumer's namespace — a real + library would use `ll_list_t`, `ll_append`, and so on. + +➕ Adding a `.c` file to `src/` or `tests/` needs **no** Makefile change. + +--- + +## ⚠️ Honest caveats + +Worth saying out loud, since this is a template people will copy: + +- 📉 **A singly linked list is usually the wrong data structure.** A dynamic + array beats it on nearly every real workload — better cache behaviour, fewer + allocations, cheaper iteration. It is here because it exercises the + scaffolding, not because it is a recommendation. +- 🔢 **The shared library is unversioned.** It records a soname of + `liblinkedlist.so` (install name `@rpath/liblinkedlist.dylib`). Fine for a + template and for static linking. Anything you actually distribute needs a + version in the soname plus the usual symlink chain, so consumers keep working + when you break ABI. +- 🧵 **Nothing here is thread-safe.** Concurrent access needs your own locking. +- 🐢 **`list_at` is O(n)**, so walking with it is O(n²). Use `list_first` / + `node_next` for a full traversal. +- 🧬 **There is no type safety.** `void *` plus a byte count is the C way, but + nothing stops you storing an `int` and reading a `double`. `list_pop` checks + the size; the borrow functions leave it to you. + +--- + +## 🤝 Contributing + +PRs welcome. Before you open one: + +```bash +make format-check # 🖊️ formatting +make test # 🧪 tests pass +make sanitize # 🧹 clean under ASan + UBSan +make memcheck # 💧 no leaks +``` + +CI runs all four across GCC 14, Clang 18, and Apple Clang, so it is worth +checking at least two compilers locally: + +```bash +make clean && make test +make clean && make CC=clang test +``` -The macOS leg is not redundant: Apple Clang has C23 but not `[[reproducible]]`, -so it is what proves the attribute guards in the header work. +--- -## License +## 📄 License -Apache License 2.0. See [LICENSE](LICENSE). +Apache License 2.0 — see [LICENSE](LICENSE). diff --git a/docs/C23.md b/docs/C23.md index 886a9e2..4e4aa1f 100644 --- a/docs/C23.md +++ b/docs/C23.md @@ -37,7 +37,7 @@ extensions too, but the seven below are portable. | `[[deprecated]]` | almost any declaration | Warn on use | Documented below | | `[[fallthrough]]` | a null statement inside `switch` | Allow deliberate fallthrough without a warning | No `switch` in this library | | `[[noreturn]]` | functions | The function never returns | `die()` in tests/main.c | -| `[[reproducible]]` | function **types** | Effectless and idempotent | `list_size`, `list_is_empty` | +| `[[reproducible]]` | function **types** | Effectless and idempotent | The six read-only accessors | | `[[unsequenced]]` | function **types** | Also stateless and independent — a pure function | Macro provided; nothing qualifies | Both `[[nodiscard]]` and `[[deprecated]]` take an optional message: @@ -67,9 +67,13 @@ position. Every function in this library reads through a pointer parameter, so the result depends on memory the caller did not pass by value. That fails *independent*, -which is why `list_size` is marked `reproducible` and nothing here is +which is why the accessors are marked `reproducible` and nothing here is `unsequenced`. `unsequenced` is for things like `int square(int n)`. +`list_at` is deliberately **not** marked, even though it only reads the list. +It writes the element size back through an out-parameter, and that is an +observable side effect — precisely what *effectless* rules out. + Getting this wrong is not a warning — it is a promise to the optimizer. If a function marked `unsequenced` actually reads global state, the compiler is free to cache a stale result. @@ -119,7 +123,7 @@ They have no honest home in a linked list, but this is what they look like: static void on_event([[maybe_unused]] void *user_data) { } // Softly retiring an API. The message shows up in the compiler diagnostic. -[[deprecated("use list_append_value instead")]] bool list_add(List *, void *); +[[deprecated("use list_append instead")]] bool list_add(List *, void *); switch (kind) { case A: @@ -140,12 +144,51 @@ switch (kind) { | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `nullptr` / `nullptr_t` | Everywhere `NULL` used to be. It has a real type, so it never silently converts to `int` in a variadic call. | | `bool`, `true`, `false` | Keywords now — the library never includes ``. | -| `static_assert` | A keyword, so no ``. The message argument is optional in C23. | +| `static_assert` | `ITEM_COUNT` in tests/main.c. A keyword, so no ``. The message argument is optional in C23. | | `constexpr` | `ITEM_COUNT` in tests/main.c. Unlike an `enum` constant it carries a real type; unlike `const size_t` it is a constant expression. C23 has it for *objects* only, not functions. | -| `typeof` | The `LIST_APPEND_LITERAL` macro, and the `static_assert` in src/linkedlist.c. A GNU extension for 30 years, now standard, so it survives `-Wpedantic`. | +| `typeof` | The `LIST_APPEND_LITERAL` macro. A GNU extension for 30 years, now standard, so it survives `-Wpedantic`. | +| `alignas` | `struct Node` in src/linkedlist.c. A keyword in C23, so no ``. See below — it is load-bearing, not decoration. | +| `ckd_add` | `node_new` in src/linkedlist.c, from the new ``. | | `(void)` prototypes | C23 removed K&R declarations, so an empty `()` now means `(void)`. Written out anyway — `-Wstrict-prototypes` is on and old habits read as ambiguous. | | Designated initializers | The `TestCase` table (C99, but worth keeping consistent). | +### `alignas` on a flexible array member + +`struct Node` keeps each element's bytes in the same allocation as the node: + +```c +struct Node { + struct Node *next; + size_t size; + alignas(max_align_t) unsigned char data[]; +}; +``` + +A flexible array of `unsigned char` is only guaranteed byte alignment, but the +payload can be any type the caller stores — including a `double`. Reading one +from a misaligned address is undefined behaviour. `alignas(max_align_t)` gives +the array the alignment `malloc` would have guaranteed for a separate block, +which is what the previous two-allocation design got for free. + +`test_alignment_of_payload` in tests/main.c pins this down, and UBSan's +alignment check would catch a regression. + +### `ckd_add` for the allocation size + +```c +size_t total; +if (ckd_add(&total, sizeof(Node), size)) { + return nullptr; // would have wrapped +} +Node *node = malloc(total); +``` + +`sizeof(Node) + size` wraps for a `size` near `SIZE_MAX`, and a wrapped total +allocates a few bytes before `memcpy` writes gigabytes into them. +`` is new in C23 and returns `true` on overflow instead of +producing it. `ckd_sub` and `ckd_mul` are the other two; `ckd_mul` is the one +you want for the far more common `count * sizeof(T)`. + ## Deliberately not used - **`#embed`** — no binary assets to inline. @@ -157,10 +200,6 @@ switch (kind) { formats bit patterns. - **Digit separators (`1'000'000`)** — no long literals. - **`unreachable()`** from `` — no exhaustive `switch` to terminate. -- **``** (`ckd_add`, `ckd_sub`, `ckd_mul`) — worth reaching for the - moment a library computes an allocation size from user-supplied values, e.g. - `count * sizeof(T)`. This list allocates exactly the `size_t` it is handed, - so there is nothing to overflow. - **`memset_explicit`** — nothing here holds secrets that must be scrubbed. ## Behaviour changes worth knowing diff --git a/include/linkedlist.h b/include/linkedlist.h index 9ccb2d7..95edf3c 100644 --- a/include/linkedlist.h +++ b/include/linkedlist.h @@ -5,21 +5,50 @@ * * A singly linked list that stores a byte-wise copy of each element. * - * The list owns everything it holds. Values handed to \ref list_append_value - * and \ref list_prepend_value are copied into freshly allocated storage, so - * the caller keeps ownership of the original and may free it, reuse it, or - * let it go out of scope immediately afterwards. Nodes handed to - * \ref list_append and \ref list_prepend are the opposite: the list takes - * ownership and frees them in \ref list_destroy. - * - * Because each node records the \ref Node::size of its payload, one list can - * hold elements of different types. Reading them back is the caller's problem - * -- nothing here checks that the \c void* you get out matches the type you - * put in. + * \ref List and \ref Node are **opaque**: this header declares them without + * defining them, so their layout lives entirely in linkedlist.c. Callers + * cannot reach past the functions below, which means the list's invariants + * cannot be broken from outside and the representation can change without + * breaking a single caller. See \ref opaque for what that bought. + * + * Everything is copied. \ref list_append and \ref list_prepend read \p size + * bytes out of the value you hand them, so you keep ownership of the original + * and may free it, reuse it, or let it go out of scope immediately. Nothing + * in this API transfers ownership in either direction; the only thing you + * must remember to call is \ref list_destroy. + * + * Because each element records its own size, one list can hold elements of + * different types. Reading them back is the caller's problem -- nothing here + * checks that the bytes you get out mean what you think they do, with the one + * exception of \ref list_pop, which refuses a size that does not match. * * This header is written against C23 and uses `nullptr`, `bool` as a keyword, - * and `[[attributes]]`. See docs/C23.md for what each feature buys and how - * the attribute portability macros below work. + * and `[[attributes]]`. See docs/C23.md. + * + * \section opaque Why the types are opaque + * + * An earlier version of this header defined both structs in full. Three + * things followed from that, all of which are now impossible to express: + * + * - Appending the same node twice was accepted, leaving one node whose + * `next` pointed at itself while the count said 2. Destroying that list + * never terminated. + * - A node the caller had allocated by hand -- rather than through the + * library -- was accepted with an uninitialised payload pointer, which + * teardown then passed to `free`. + * - Every read went through `list->head->data`, so the struct layout was + * part of the ABI and no consumer could be recompiled independently. + * + * Hiding the layout also let the implementation put each element's bytes in + * the same allocation as its node, halving the calls to `malloc`. + * + * \section invalidation Pointer and cursor invalidation + * + * \ref list_at, \ref list_at_mut and \ref node_value hand out pointers into + * the list's own storage, and \ref list_first / \ref node_next hand out + * cursors. Any call that adds or removes an element invalidates every pointer + * and cursor to the elements that were removed. Copy the bytes out with + * \ref list_pop if you need them to outlive the list. */ // A hard error beats the cascade of confusing syntax errors a pre-C23 @@ -54,6 +83,11 @@ * * Placing them in the leading position is a constraint violation; GCC * diagnoses it with "can only be applied to function declarators". + * + * Only the functions that return a value and touch nothing else carry it. + * \ref list_at is *not* marked, even though it only reads the list: it writes + * the element size back through an out-parameter, and that is an observable + * side effect, which is exactly what "effectless" rules out. */ #if defined(__has_c_attribute) #if __has_c_attribute(reproducible) @@ -86,31 +120,34 @@ #endif /** - * One element of the list, together with the payload it owns. + * A list. Create one with \ref list_new and release it with + * \ref list_destroy. + * + * The layout is private to linkedlist.c, so this is an incomplete type: you + * can hold a `List *` but you cannot dereference it, take its `sizeof`, or + * allocate one yourself. */ -typedef struct Node { - /** Size of \ref data in bytes, as passed when the node was created. */ - size_t size; - /** The element itself: \ref size bytes owned by this node, or `nullptr`. */ - void *data; - /** The next element, or `nullptr` at the end of the list. */ - struct Node *next; -} Node; +typedef struct List List; /** - * The list itself. + * A read-only cursor to one element, used to walk a list. * - * \ref tail exists so \ref list_append is O(1) instead of walking the chain. - * This is not a circular list: the last node's `next` is always `nullptr`. + * Obtain the first with \ref list_first and advance with \ref node_next: + * + * ```c + * for (const Node *n = list_first(list); n != nullptr; n = node_next(n)) { + * const int *value = node_value(n); + * } + * ``` + * + * A cursor is only valid until the list is next modified. See + * \ref invalidation. */ -typedef struct List { - /** First element, or `nullptr` when the list is empty. */ - Node *head; - /** Last element, or `nullptr` when the list is empty. */ - Node *tail; - /** Number of elements currently in the list. */ - size_t size; -} List; +typedef struct Node Node; + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- /** * Allocate an empty list. @@ -123,7 +160,7 @@ typedef struct List { [[nodiscard]] List *list_new(void); /** - * Free a list and every node still in it, payloads included. + * Free a list and every element in it. * * Passing `nullptr` is a no-op, which mirrors `free` and means teardown paths * do not need their own null check. @@ -133,110 +170,161 @@ typedef struct List { void list_destroy(List *list); /** - * Allocate a node holding a copy of \p value. + * Remove every element, leaving an empty but still usable list. * - * \p size bytes are copied out of \p value, so the caller keeps ownership of - * the original. A \p size of 0 produces a node whose \ref Node::data is - * `nullptr`, which is the only case where \p value may itself be `nullptr`. + * Passing `nullptr` is a no-op. * - * \param value the element to copy in. - * \param size the number of bytes to copy, typically `sizeof(T)`. - * \return a detached node the caller must either insert into a list or free - * with \ref list_node_destroy, or `nullptr` if the allocation failed - * or the arguments were inconsistent. + * \param list the list to empty. + */ +void list_clear(List *list); + +// --------------------------------------------------------------------------- +// Insertion +// --------------------------------------------------------------------------- + +/** + * Copy \p value in as the new first element. * - * \sa list_node_destroy + * \param list the list to insert into. + * \param value the element to copy in. Must not be `nullptr`. + * \param size the number of bytes to copy, typically `sizeof(T)`. Must not + * be 0 -- a zero-byte element would be indistinguishable from + * "absent" in every function that returns a borrowed pointer. + * \return `true` on success, `false` on bad arguments or allocation failure. */ -[[nodiscard]] Node *list_node_new(const void *value, size_t size); +[[nodiscard]] bool list_prepend(List *list, const void *value, size_t size); /** - * Free a single node and its payload. + * Copy \p value in as the new last element, in O(1). * - * Only call this on a node that is *not* in a list -- one you got back from - * \ref list_pop or \ref list_node_new. Freeing a node that is still linked - * leaves the list pointing at released memory. + * \param list the list to insert into. + * \param value the element to copy in. Must not be `nullptr`. + * \param size the number of bytes to copy, typically `sizeof(T)`. Must not be 0. + * \return `true` on success, `false` on bad arguments or allocation failure. + */ +[[nodiscard]] bool list_append(List *list, const void *value, size_t size); + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +/** + * Number of elements in the list. * - * Passing `nullptr` is a no-op. + * \param list the list to measure, or `nullptr`. + * \return the element count, or 0 for a `nullptr` list. + */ +[[nodiscard]] size_t list_size(const List *list) LINKEDLIST_REPRODUCIBLE; + +/** + * Whether the list holds no elements. * - * \param node the node to free. + * \param list the list to test, or `nullptr`. + * \return `true` when the list is empty or `nullptr`. */ -void list_node_destroy(Node *node); +[[nodiscard]] bool list_is_empty(const List *list) LINKEDLIST_REPRODUCIBLE; /** - * Insert an existing node at the front of the list. + * Borrow a read-only pointer to the element at \p index. + * + * Walking the whole list this way is O(n^2); use \ref list_first and + * \ref node_next for that. + * + * \param list the list to read from, or `nullptr`. + * \param index a zero-based position. + * \param size where to write the element's size in bytes. May be `nullptr` + * if you already know it. Left untouched when the lookup fails. + * \return a pointer into the list's storage, or `nullptr` if \p index is out + * of range. Valid until the list is next modified; see + * \ref invalidation. + */ +[[nodiscard]] const void *list_at(const List *list, size_t index, size_t *size); + +/** + * Borrow a writable pointer to the element at \p index. * - * On success the list takes ownership of \p node and will free it in - * \ref list_destroy. On failure ownership stays with the caller. + * Writing more than \p size bytes through it overruns the element. * - * \param list the list to insert into. - * \param node the node to insert. - * \return `true` on success, `false` if either argument was `nullptr`. + * \param list the list to read from, or `nullptr`. + * \param index a zero-based position. + * \param size where to write the element's size in bytes, or `nullptr`. + * \return a pointer into the list's storage, or `nullptr` if \p index is out + * of range. See \ref invalidation. */ -[[nodiscard]] bool list_prepend(List *list, Node *node); +[[nodiscard]] void *list_at_mut(List *list, size_t index, size_t *size); + +// --------------------------------------------------------------------------- +// Iteration +// --------------------------------------------------------------------------- /** - * Insert an existing node at the back of the list, in O(1). + * Cursor to the first element. * - * Ownership follows the same rule as \ref list_prepend. + * \param list the list to walk, or `nullptr`. + * \return a cursor, or `nullptr` if the list is empty or `nullptr`. * - * \param list the list to insert into. - * \param node the node to insert. - * \return `true` on success, `false` if either argument was `nullptr`. + * \sa node_next */ -[[nodiscard]] bool list_append(List *list, Node *node); +[[nodiscard]] const Node *list_first(const List *list) LINKEDLIST_REPRODUCIBLE; /** - * Copy \p value into a new node at the front of the list. + * Advance a cursor. * - * Equivalent to \ref list_node_new followed by \ref list_prepend, without the - * intermediate node to clean up on failure. + * \param node the current cursor, or `nullptr`. + * \return the next cursor, or `nullptr` at the end of the list. + */ +[[nodiscard]] const Node *node_next(const Node *node) LINKEDLIST_REPRODUCIBLE; + +/** + * Borrow a read-only pointer to the element a cursor points at. * - * \param list the list to insert into. - * \param value the element to copy in. - * \param size the number of bytes to copy, typically `sizeof(T)`. - * \return `true` on success, `false` on allocation failure or bad arguments. + * \param node the cursor, or `nullptr`. + * \return a pointer into the list's storage, or `nullptr` for a `nullptr` + * cursor. See \ref invalidation. */ -[[nodiscard]] bool list_prepend_value(List *list, const void *value, size_t size); +[[nodiscard]] const void *node_value(const Node *node) LINKEDLIST_REPRODUCIBLE; /** - * Copy \p value into a new node at the back of the list. + * Size in bytes of the element a cursor points at. * - * \param list the list to insert into. - * \param value the element to copy in. - * \param size the number of bytes to copy, typically `sizeof(T)`. - * \return `true` on success, `false` on allocation failure or bad arguments. + * \param node the cursor, or `nullptr`. + * \return the size, or 0 for a `nullptr` cursor. */ -[[nodiscard]] bool list_append_value(List *list, const void *value, size_t size); +[[nodiscard]] size_t node_size(const Node *node) LINKEDLIST_REPRODUCIBLE; + +// --------------------------------------------------------------------------- +// Removal +// --------------------------------------------------------------------------- /** - * Detach and return the first node. + * Copy the first element into \p out and remove it. * - * The node comes back with its `next` cleared and its payload intact, and the - * caller becomes responsible for it -- pass it to \ref list_node_destroy or - * insert it into another list. + * \p size must equal the stored element's size exactly. A mismatch is treated + * as a caller bug -- almost always the wrong type -- so the call fails and the + * element stays in the list rather than copying a partial or oversized value. + * Ask \ref list_at for the size first if you do not know it. * * \param list the list to pop from. - * \return the former head, or `nullptr` if \p list was `nullptr` or empty. + * \param out where to copy the element to. Must have room for \p size bytes. + * \param size the expected element size, typically `sizeof(T)`. + * \return `true` if an element was copied out and removed, `false` if the + * list was empty or `nullptr`, \p out was `nullptr`, or \p size did + * not match. * - * \sa list_node_destroy + * \sa list_drop */ -[[nodiscard]] Node *list_pop(List *list); +[[nodiscard]] bool list_pop(List *list, void *out, size_t size); /** - * Number of elements in the list. + * Remove the first element without copying it anywhere. * - * \param list the list to measure, or `nullptr`. - * \return the element count, or 0 for a `nullptr` list. - */ -[[nodiscard]] size_t list_size(const List *list) LINKEDLIST_REPRODUCIBLE; - -/** - * Whether the list holds no elements. + * \param list the list to drop from, or `nullptr`. + * \return `true` if an element was removed, `false` if the list was empty or + * `nullptr`. * - * \param list the list to test, or `nullptr`. - * \return `true` when the list is empty or `nullptr`. + * \sa list_pop */ -[[nodiscard]] bool list_is_empty(const List *list) LINKEDLIST_REPRODUCIBLE; +[[nodiscard]] bool list_drop(List *list); /** * \def LIST_APPEND_LITERAL @@ -252,9 +340,10 @@ void list_node_destroy(Node *node); * C23 standardised `typeof`, which had been a GNU extension for decades, so * this now compiles under `-Wpedantic`. The compound literal it builds lives * until the end of the enclosing block, which is long enough because - * \ref list_append_value copies out of it before returning. + * \ref list_append copies out of it before returning. * - * \p expr is evaluated exactly once. + * \p expr is evaluated exactly once: `typeof` and `sizeof` do not evaluate + * their operands. */ #define LIST_APPEND_LITERAL(list, expr) \ - list_append_value((list), &(typeof(expr)){(expr)}, sizeof(typeof(expr))) + list_append((list), &(typeof(expr)){(expr)}, sizeof(typeof(expr))) diff --git a/src/linkedlist.c b/src/linkedlist.c index 98f6cb3..7813577 100644 --- a/src/linkedlist.c +++ b/src/linkedlist.c @@ -3,105 +3,207 @@ * * Implementation of the list declared in include/linkedlist.h. * - * Two invariants hold between every public call and are worth keeping in mind - * when editing: + * The two struct definitions below are the whole reason that header can + * promise what it promises: nothing outside this file can see them, so + * nothing outside this file can put a list into a state the functions here + * would not produce. * - * 1. `size == 0` if and only if `head == nullptr` and `tail == nullptr`. + * Three invariants hold between every public call: + * + * 1. `count == 0` if and only if `head == nullptr` and `tail == nullptr`. * 2. The last node's `next` is always `nullptr`. This is not a circular list. + * 3. Every node reachable from `head` was allocated by \ref node_new and is + * owned by exactly one list. */ #include "linkedlist.h" +#include #include #include -// C23 promotes static_assert to a keyword (no needed) and -// standardises typeof. list_node_new passes Node::size straight to malloc, so -// pin the type down here instead of leaving the assumption implicit. The -// pointer is never dereferenced: typeof, like sizeof, does not evaluate its -// operand. -static_assert( - sizeof(typeof(((Node *)nullptr)->size)) == sizeof(size_t), - "Node::size must stay a size_t byte count" -); +/** + * One element: its link, its length, and its bytes, in a single allocation. + * + * `data` is a flexible array member, so `malloc(sizeof(Node) + size)` puts the + * payload immediately after the header instead of in a second allocation. That + * halves the calls to malloc and free, and keeps an element's bytes in the + * same cache line as the `next` pointer that led to them. The public header + * used to expose a `void *data` pointing at a separate block; making the type + * opaque is what allowed the change, and no caller had to be touched. + * + * `alignas(max_align_t)` is doing real work. A flexible array of + * `unsigned char` is only guaranteed to be byte-aligned, but the payload can + * be any type the caller likes, so the array has to start somewhere suitable + * for all of them -- which is exactly what malloc would have guaranteed for a + * separate block. Without it, storing a `double` here would be undefined + * behaviour on any target that cares about alignment. C23 made `alignas` a + * keyword, so is no longer needed. + */ +struct Node { + struct Node *next; + size_t size; + alignas(max_align_t) unsigned char data[]; +}; -List *list_new(void) { - List *list = malloc(sizeof(List)); - if (list == nullptr) { +/** + * `tail` exists so appending is O(1) instead of walking the chain. + * + * The count is named `count` rather than `size` because a node's `size` is a + * byte count and a list's is an element count; the old header used the same + * word for both, one screen apart. + */ +struct List { + Node *head; + Node *tail; + size_t count; +}; + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +/** + * Allocate a detached node holding a copy of \p value. + * + * \return the node, or `nullptr` on bad arguments, an allocation failure, or + * a size so large the header could not be added to it. + */ +[[nodiscard]] static Node *node_new(const void *value, size_t size) { + // A zero-size element would come back from list_at as a non-null pointer to + // nothing, indistinguishable from a real one, so it is rejected outright. + if (value == nullptr || size == 0) { return nullptr; } - list->head = nullptr; - list->tail = nullptr; - list->size = 0; + // sizeof(Node) + size can wrap for a size near SIZE_MAX, and a wrapped + // total would allocate a few bytes and then memcpy gigabytes into them. + // ckd_add reports the overflow instead of silently producing it; it is new + // in C23, in . + size_t total; + if (ckd_add(&total, sizeof(Node), size)) { + return nullptr; + } - return list; + Node *node = malloc(total); + if (node == nullptr) { + return nullptr; + } + + node->next = nullptr; + node->size = size; + + // The copy is the whole point: the caller keeps ownership of `value` and + // may free it the moment this returns. + memcpy(node->data, value, size); + + return node; } -void list_destroy(List *list) { - // Tolerating nullptr mirrors free() and keeps error paths short: a caller - // whose list_new() failed can still fall through to cleanup. - if (list == nullptr) { - return; +/** + * Detach the first node and hand it back, or `nullptr` if there is none. + * + * The caller must free it. Shared by \ref list_pop and \ref list_drop so the + * bookkeeping exists once. + */ +[[nodiscard]] static Node *unlink_head(List *list) { + if (list == nullptr || list->head == nullptr) { + return nullptr; } Node *node = list->head; - while (node != nullptr) { - // Read next before freeing, not after. - Node *next = node->next; - list_node_destroy(node); - node = next; + list->head = node->next; + list->count--; + + // Removing the last element has to clear the tail too, or the next append + // would write through a pointer to freed memory. + if (list->head == nullptr) { + list->tail = nullptr; } - free(list); + node->next = nullptr; + + return node; } -Node *list_node_new(const void *value, size_t size) { - // A zero-size element is the one case where value may be null. Any other - // null value would leave the payload uninitialised, so reject it. - if (value == nullptr && size != 0) { +/** + * Walk to the node at \p index, or `nullptr` if it is out of range. + * + * Takes a `const List *` but returns a mutable `Node *`, which is not a const + * violation: the constness of a struct does not propagate to the objects its + * members point at, so reading `list->head` here yields a `Node *const`, not a + * pointer to const. That is what lets list_at and list_at_mut share this. + */ +[[nodiscard]] static Node *node_at(const List *list, size_t index) { + if (list == nullptr || index >= list->count) { return nullptr; } - Node *node = malloc(sizeof(Node)); - if (node == nullptr) { + Node *node = list->head; + for (size_t i = 0; i < index; i++) { + node = node->next; + } + + return node; +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +List *list_new(void) { + List *list = malloc(sizeof(List)); + if (list == nullptr) { return nullptr; } - node->next = nullptr; - node->size = size; - node->data = nullptr; + list->head = nullptr; + list->tail = nullptr; + list->count = 0; - if (size == 0) { - // malloc(0) may legally return either nullptr or a unique pointer, so - // normalise on nullptr instead of letting the platform decide. - return node; + return list; +} + +void list_clear(List *list) { + if (list == nullptr) { + return; } - node->data = malloc(size); - if (node->data == nullptr) { + Node *node = list->head; + while (node != nullptr) { + // Read next before freeing, not after. + Node *next = node->next; free(node); - return nullptr; + node = next; } - // The copy is the whole point: the caller keeps ownership of `value` and - // may free it the moment this returns. - memcpy(node->data, value, size); - - return node; + list->head = nullptr; + list->tail = nullptr; + list->count = 0; } -void list_node_destroy(Node *node) { - if (node == nullptr) { +void list_destroy(List *list) { + // Tolerating nullptr mirrors free() and keeps error paths short: a caller + // whose list_new() failed can still fall through to cleanup. + if (list == nullptr) { return; } - free(node->data); - free(node); + list_clear(list); + free(list); } -bool list_prepend(List *list, Node *node) { - if (list == nullptr || node == nullptr) { +// --------------------------------------------------------------------------- +// Insertion +// --------------------------------------------------------------------------- + +bool list_prepend(List *list, const void *value, size_t size) { + if (list == nullptr) { + return false; + } + + Node *node = node_new(value, size); + if (node == nullptr) { return false; } @@ -112,95 +214,125 @@ bool list_prepend(List *list, Node *node) { node->next = list->head; list->head = node; - list->size++; + list->count++; return true; } -bool list_append(List *list, Node *node) { - if (list == nullptr || node == nullptr) { +bool list_append(List *list, const void *value, size_t size) { + if (list == nullptr) { return false; } - node->next = nullptr; + Node *node = node_new(value, size); + if (node == nullptr) { + return false; + } if (list->head == nullptr) { list->head = node; list->tail = node; - list->size++; + list->count++; // Returning here matters. Falling through to `list->tail->next = node` // below would set node->next to node itself and make a one-element list - // circular, which list_destroy would then walk forever. + // circular, which list_clear would then walk forever. return true; } list->tail->next = node; list->tail = node; - list->size++; + list->count++; return true; } -bool list_prepend_value(List *list, const void *value, size_t size) { - if (list == nullptr) { - return false; - } +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- - Node *node = list_node_new(value, size); +size_t list_size(const List *list) LINKEDLIST_REPRODUCIBLE { + return list == nullptr ? 0 : list->count; +} + +bool list_is_empty(const List *list) LINKEDLIST_REPRODUCIBLE { + return list_size(list) == 0; +} + +const void *list_at(const List *list, size_t index, size_t *size) { + const Node *node = node_at(list, index); if (node == nullptr) { - return false; + return nullptr; } - if (!list_prepend(list, node)) { - list_node_destroy(node); - return false; + if (size != nullptr) { + *size = node->size; } - return true; + return node->data; } -bool list_append_value(List *list, const void *value, size_t size) { - if (list == nullptr) { - return false; - } - - Node *node = list_node_new(value, size); +void *list_at_mut(List *list, size_t index, size_t *size) { + Node *node = node_at(list, index); if (node == nullptr) { - return false; + return nullptr; } - if (!list_append(list, node)) { - list_node_destroy(node); - return false; + if (size != nullptr) { + *size = node->size; } - return true; + return node->data; } -Node *list_pop(List *list) { - if (list == nullptr || list->head == nullptr) { - return nullptr; - } +// --------------------------------------------------------------------------- +// Iteration +// --------------------------------------------------------------------------- - Node *node = list->head; - list->head = node->next; - list->size--; +const Node *list_first(const List *list) LINKEDLIST_REPRODUCIBLE { + return list == nullptr ? nullptr : list->head; +} - // Popping the last element has to clear the tail too, or the next append - // would write through a pointer to freed memory. - if (list->head == nullptr) { - list->tail = nullptr; - } +const Node *node_next(const Node *node) LINKEDLIST_REPRODUCIBLE { + return node == nullptr ? nullptr : node->next; +} - node->next = nullptr; +const void *node_value(const Node *node) LINKEDLIST_REPRODUCIBLE { + return node == nullptr ? nullptr : node->data; +} - return node; +size_t node_size(const Node *node) LINKEDLIST_REPRODUCIBLE { + return node == nullptr ? 0 : node->size; } -size_t list_size(const List *list) LINKEDLIST_REPRODUCIBLE { - return list == nullptr ? 0 : list->size; +// --------------------------------------------------------------------------- +// Removal +// --------------------------------------------------------------------------- + +bool list_pop(List *list, void *out, size_t size) { + if (list == nullptr || out == nullptr || list->head == nullptr) { + return false; + } + + // Checked before unlinking, so a mismatch leaves the list untouched rather + // than destroying an element the caller could not read. + if (list->head->size != size) { + return false; + } + + Node *node = unlink_head(list); + memcpy(out, node->data, size); + free(node); + + return true; } -bool list_is_empty(const List *list) LINKEDLIST_REPRODUCIBLE { - return list_size(list) == 0; +bool list_drop(List *list) { + Node *node = unlink_head(list); + if (node == nullptr) { + return false; + } + + free(node); + + return true; } diff --git a/tests/main.c b/tests/main.c index 7f7fd3c..2897b35 100644 --- a/tests/main.c +++ b/tests/main.c @@ -3,6 +3,12 @@ * * Test suite for the linkedlist library. * + * Every test here goes through the public API and nothing else. That is not + * discipline, it is enforced: `List` and `Node` are incomplete types outside + * linkedlist.c, so `list->head` does not compile. The previous version of this + * file reached into `list->head->data` constantly, which is what made the + * struct layout part of the ABI. + * * The checks deliberately do not use `assert`. `assert(list_append(...))` * looks fine until someone builds with `-DNDEBUG`, at which point the macro * expands to nothing, the call disappears along with it, and the suite passes @@ -11,6 +17,9 @@ #include "linkedlist.h" +#include // SIZE_MAX, uintptr_t -- both arrive transitively via + // on this platform, but that is an SDK + // accident, not something C guarantees. #include #include #include @@ -61,6 +70,19 @@ static void check(bool ok, const char *expr, const char *file, int line) { return list; } +/** Read element \p index as an int, or a sentinel if it is missing or wrong-sized. */ +[[nodiscard]] static int int_at(const List *list, size_t index) { + size_t size = 0; + const void *value = list_at(list, index, &size); + if (value == nullptr || size != sizeof(int)) { + return -1; + } + + int out; + memcpy(&out, value, sizeof out); + return out; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -68,18 +90,17 @@ static void check(bool ok, const char *expr, const char *file, int line) { static void test_new_list_is_empty(void) { List *list = new_list_or_die(); - CHECK(list->size == 0); - CHECK(list->head == nullptr); - CHECK(list->tail == nullptr); CHECK(list_size(list) == 0); CHECK(list_is_empty(list)); + CHECK(list_first(list) == nullptr); + CHECK(list_at(list, 0, nullptr) == nullptr); list_destroy(list); } /** * Regression test: appending to an *empty* list used to link the node to - * itself, so a one-element list was circular and list_destroy never returned. + * itself, so a one-element list was circular and teardown never returned. * Two or more appends papered over it, which is why the original suite -- it * only ever appended ten at a time -- did not catch it. */ @@ -87,62 +108,67 @@ static void test_append_to_empty_list(void) { List *list = new_list_or_die(); const int value = 42; - CHECK(list_append_value(list, &value, sizeof value)); + CHECK(list_append(list, &value, sizeof value)); CHECK(list_size(list) == 1); - CHECK(list->head == list->tail); - CHECK(list->head->next == nullptr); + CHECK(int_at(list, 0) == 42); + + // A self-link would make this walk run forever instead of stopping at one. + size_t walked = 0; + for (const Node *n = list_first(list); n != nullptr; n = node_next(n)) { + walked++; + if (walked > 2) { + die("iteration did not terminate -- the list is circular"); + } + } + CHECK(walked == 1); list_destroy(list); // Hangs forever if the self-link ever comes back. } -static void test_prepend_many(void) { +static void test_append_order(void) { List *list = new_list_or_die(); for (size_t i = 0; i < ITEM_COUNT; i++) { const int value = (int)i; - CHECK(list_prepend_value(list, &value, sizeof value)); + CHECK(list_append(list, &value, sizeof value)); } CHECK(list_size(list) == ITEM_COUNT); - // Prepending reverses insertion order: the last one in is at the head. - const Node *node = list->head; - for (size_t i = 0; i < ITEM_COUNT; i++) { - CHECK(node != nullptr); - CHECK(node->size == sizeof(int)); - CHECK(*(const int *)node->data == (int)(ITEM_COUNT - 1 - i)); - node = node->next; + // Appending preserves insertion order. + size_t index = 0; + for (const Node *n = list_first(list); n != nullptr; n = node_next(n)) { + CHECK(node_size(n) == sizeof(int)); + CHECK(*(const int *)node_value(n) == (int)index); + CHECK(int_at(list, index) == (int)index); + index++; } - CHECK(node == nullptr); + CHECK(index == ITEM_COUNT); list_destroy(list); } -static void test_append_many(void) { +static void test_prepend_order(void) { List *list = new_list_or_die(); for (size_t i = 0; i < ITEM_COUNT; i++) { const int value = (int)i; - CHECK(list_append_value(list, &value, sizeof value)); + CHECK(list_prepend(list, &value, sizeof value)); } CHECK(list_size(list) == ITEM_COUNT); - CHECK(list->tail->next == nullptr); - const Node *node = list->head; + // Prepending reverses it: the last one in is at the head. for (size_t i = 0; i < ITEM_COUNT; i++) { - CHECK(node != nullptr); - CHECK(*(const int *)node->data == (int)i); - node = node->next; + CHECK(int_at(list, i) == (int)(ITEM_COUNT - 1 - i)); } - CHECK(node == nullptr); list_destroy(list); } /** - * Regression test: the *_value functions used to malloc a buffer and then + * Regression test: the insertion functions used to malloc a buffer and then * immediately overwrite the pointer with the caller's, leaking the buffer and * silently aliasing caller-owned memory. They copy now, so mutating or * freeing the source afterwards must not disturb the list. @@ -156,12 +182,12 @@ static void test_value_is_copied(void) { } *source = 7; - CHECK(list_append_value(list, source, sizeof *source)); + CHECK(list_append(list, source, sizeof *source)); *source = 999; free(source); - CHECK(*(const int *)list->head->data == 7); + CHECK(int_at(list, 0) == 7); list_destroy(list); } @@ -175,24 +201,74 @@ static void test_struct_element(void) { List *list = new_list_or_die(); const Record in = {.id = 3, .name = "abc"}; - CHECK(list_append_value(list, &in, sizeof in)); + CHECK(list_append(list, &in, sizeof in)); - const Record *out = list->head->data; - CHECK(list->head->size == sizeof(Record)); + size_t size = 0; + const Record *out = list_at(list, 0, &size); + CHECK(size == sizeof(Record)); + CHECK(out != nullptr); CHECK(out->id == 3); CHECK(strcmp(out->name, "abc") == 0); list_destroy(list); } -static void test_zero_size_element(void) { +/** + * The payload sits in a flexible array member inside the node, so it has to + * be aligned for any type the caller might store -- not just for bytes. A + * misaligned double is undefined behaviour, and UBSan's alignment check is + * what would catch a regression here. + */ +static void test_alignment_of_payload(void) { List *list = new_list_or_die(); - // A zero-size element is the only case where a null value is accepted. - CHECK(list_append_value(list, nullptr, 0)); - CHECK(list_size(list) == 1); - CHECK(list->head->size == 0); - CHECK(list->head->data == nullptr); + // A one-byte element first, to push the next node's payload off any + // accidental alignment it would otherwise inherit. + const char pad = 'x'; + CHECK(list_append(list, &pad, sizeof pad)); + + const double value = 1234.5; + CHECK(list_append(list, &value, sizeof value)); + + size_t size = 0; + const void *raw = list_at(list, 1, &size); + CHECK(size == sizeof(double)); + CHECK(raw != nullptr); + CHECK((uintptr_t)raw % alignof(double) == 0); + CHECK(*(const double *)raw == 1234.5); + + list_destroy(list); +} + +static void test_mutate_in_place(void) { + List *list = new_list_or_die(); + + const int value = 1; + CHECK(list_append(list, &value, sizeof value)); + + size_t size = 0; + int *slot = list_at_mut(list, 0, &size); + CHECK(slot != nullptr); + CHECK(size == sizeof(int)); + *slot = 99; + + CHECK(int_at(list, 0) == 99); + CHECK(list_size(list) == 1); // Mutating must not change the count. + + list_destroy(list); +} + +static void test_index_out_of_range(void) { + List *list = new_list_or_die(); + + const int value = 1; + CHECK(list_append(list, &value, sizeof value)); + + size_t size = 12345; + CHECK(list_at(list, 1, &size) == nullptr); + CHECK(size == 12345); // A failed lookup must leave the out-parameter alone. + CHECK(list_at(list, SIZE_MAX, nullptr) == nullptr); + CHECK(list_at_mut(list, 1, nullptr) == nullptr); list_destroy(list); } @@ -202,81 +278,187 @@ static void test_pop_all(void) { for (size_t i = 0; i < ITEM_COUNT; i++) { const int value = (int)i; - CHECK(list_append_value(list, &value, sizeof value)); + CHECK(list_append(list, &value, sizeof value)); } for (size_t i = 0; i < ITEM_COUNT; i++) { - Node *node = list_pop(list); - CHECK(node != nullptr); - CHECK(node->next == nullptr); - CHECK(*(const int *)node->data == (int)i); + int out = -1; + CHECK(list_pop(list, &out, sizeof out)); + CHECK(out == (int)i); CHECK(list_size(list) == ITEM_COUNT - 1 - i); - list_node_destroy(node); } CHECK(list_is_empty(list)); - CHECK(list_pop(list) == nullptr); + + int out = -1; + CHECK(!list_pop(list, &out, sizeof out)); list_destroy(list); } /** - * Regression test: popping the last element left `tail` dangling at the freed - * node, so the next append wrote through a stale pointer. Only a sanitizer or - * valgrind run would have flagged it; the list looked fine. + * A size mismatch is almost always the wrong type, so the element has to stay + * where it is rather than being destroyed on the way to a bad read. */ -static void test_append_after_popping_last(void) { +static void test_pop_with_wrong_size_keeps_element(void) { List *list = new_list_or_die(); - const int first = 1; - CHECK(list_append_value(list, &first, sizeof first)); + const int value = 5; + CHECK(list_append(list, &value, sizeof value)); - Node *popped = list_pop(list); - CHECK(popped != nullptr); - list_node_destroy(popped); + double wrong = 0; + CHECK(!list_pop(list, &wrong, sizeof wrong)); + CHECK(list_size(list) == 1); + CHECK(int_at(list, 0) == 5); + int right = 0; + CHECK(list_pop(list, &right, sizeof right)); + CHECK(right == 5); + CHECK(list_is_empty(list)); + + list_destroy(list); +} + +static void test_drop(void) { + List *list = new_list_or_die(); + + for (size_t i = 0; i < ITEM_COUNT; i++) { + const int value = (int)i; + CHECK(list_append(list, &value, sizeof value)); + } + + CHECK(list_drop(list)); + CHECK(list_size(list) == ITEM_COUNT - 1); + CHECK(int_at(list, 0) == 1); + + while (list_drop(list)) { + // Drain it. + } + + CHECK(list_is_empty(list)); + CHECK(!list_drop(list)); + + list_destroy(list); +} + +/** + * Regression test: removing the last element left `tail` dangling at the + * freed node, so the next append wrote through a stale pointer. Only a + * sanitizer or valgrind run would have flagged it; the list looked fine. + */ +static void test_append_after_removing_last(void) { + List *list = new_list_or_die(); + + const int first = 1; + CHECK(list_append(list, &first, sizeof first)); + CHECK(list_drop(list)); CHECK(list_is_empty(list)); - CHECK(list->tail == nullptr); const int second = 2; - CHECK(list_append_value(list, &second, sizeof second)); + CHECK(list_append(list, &second, sizeof second)); CHECK(list_size(list) == 1); - CHECK(*(const int *)list->head->data == 2); + CHECK(int_at(list, 0) == 2); + + // Same again through the other removal path. + int out = 0; + CHECK(list_pop(list, &out, sizeof out)); + const int third = 3; + CHECK(list_append(list, &third, sizeof third)); + CHECK(int_at(list, 0) == 3); list_destroy(list); } -static void test_node_ownership_transfer(void) { +static void test_clear_leaves_list_usable(void) { List *list = new_list_or_die(); - const int value = 5; - Node *node = list_node_new(&value, sizeof value); - if (node == nullptr) { - die("out of memory allocating a node"); + for (size_t i = 0; i < ITEM_COUNT; i++) { + const int value = (int)i; + CHECK(list_append(list, &value, sizeof value)); } - // From here the list owns the node; list_destroy frees it. - CHECK(list_prepend(list, node)); + list_clear(list); + + CHECK(list_is_empty(list)); + CHECK(list_first(list) == nullptr); + + const int value = 7; + CHECK(list_append(list, &value, sizeof value)); CHECK(list_size(list) == 1); + CHECK(int_at(list, 0) == 7); + + list_clear(list); + list_clear(list); // Clearing twice must be harmless. + CHECK(list_is_empty(list)); + + list_destroy(list); +} + +static void test_mixed_element_types(void) { + List *list = new_list_or_die(); + + const int i = 1; + const double d = 2.5; + const char s[] = "three"; + + CHECK(list_append(list, &i, sizeof i)); + CHECK(list_append(list, &d, sizeof d)); + CHECK(list_append(list, s, sizeof s)); + + CHECK(node_size(list_first(list)) == sizeof(int)); + CHECK(node_size(node_next(list_first(list))) == sizeof(double)); + + size_t size = 0; + const char *out = list_at(list, 2, &size); + CHECK(size == sizeof s); + CHECK(strcmp(out, "three") == 0); list_destroy(list); } -static void test_null_arguments_are_rejected(void) { +static void test_bad_arguments_are_rejected(void) { const int value = 1; + List *list = new_list_or_die(); - CHECK(!list_append(nullptr, nullptr)); - CHECK(!list_prepend(nullptr, nullptr)); - CHECK(!list_append_value(nullptr, &value, sizeof value)); - CHECK(!list_prepend_value(nullptr, &value, sizeof value)); - CHECK(list_pop(nullptr) == nullptr); + // Null list. + CHECK(!list_append(nullptr, &value, sizeof value)); + CHECK(!list_prepend(nullptr, &value, sizeof value)); + CHECK(!list_pop(nullptr, (int[]){0}, sizeof(int))); + CHECK(!list_drop(nullptr)); CHECK(list_size(nullptr) == 0); CHECK(list_is_empty(nullptr)); - CHECK(list_node_new(nullptr, sizeof(int)) == nullptr); + CHECK(list_at(nullptr, 0, nullptr) == nullptr); + CHECK(list_at_mut(nullptr, 0, nullptr) == nullptr); + CHECK(list_first(nullptr) == nullptr); + + // Null cursor. + CHECK(node_next(nullptr) == nullptr); + CHECK(node_value(nullptr) == nullptr); + CHECK(node_size(nullptr) == 0); + + // Null value, and the zero-size element the API no longer accepts: it would + // come back from list_at as a non-null pointer to nothing. + CHECK(!list_append(list, nullptr, sizeof value)); + CHECK(!list_prepend(list, nullptr, sizeof value)); + CHECK(!list_append(list, &value, 0)); + CHECK(!list_prepend(list, &value, 0)); + CHECK(list_is_empty(list)); + + // A size large enough that adding the node header overflows: caught by + // ckd_add rather than wrapping into a small allocation. + CHECK(!list_append(list, &value, SIZE_MAX)); + CHECK(list_is_empty(list)); + + // Null destination for pop. + CHECK(list_append(list, &value, sizeof value)); + CHECK(!list_pop(list, nullptr, sizeof value)); + CHECK(list_size(list) == 1); - // Both of these are documented no-ops, so this only has to not crash. + // Both documented no-ops, so this only has to not crash. list_destroy(nullptr); - list_node_destroy(nullptr); + list_clear(nullptr); + + list_destroy(list); } static void test_append_literal_macro(void) { @@ -287,10 +469,18 @@ static void test_append_literal_macro(void) { CHECK(LIST_APPEND_LITERAL(list, 2.5)); CHECK(list_size(list) == 2); - CHECK(list->head->size == sizeof(int)); - CHECK(*(const int *)list->head->data == 42); - CHECK(list->tail->size == sizeof(double)); - CHECK(*(const double *)list->tail->data == 2.5); + CHECK(int_at(list, 0) == 42); + + size_t size = 0; + const double *d = list_at(list, 1, &size); + CHECK(size == sizeof(double)); + CHECK(*d == 2.5); + + // The macro must evaluate its expression exactly once -- typeof and sizeof + // do not evaluate their operands, but it would be easy to break that. + int calls = 0; + CHECK(LIST_APPEND_LITERAL(list, ++calls)); + CHECK(calls == 1); list_destroy(list); } @@ -310,15 +500,20 @@ int main(void) { static const TestCase tests[] = { TEST_CASE(test_new_list_is_empty), TEST_CASE(test_append_to_empty_list), - TEST_CASE(test_prepend_many), - TEST_CASE(test_append_many), + TEST_CASE(test_append_order), + TEST_CASE(test_prepend_order), TEST_CASE(test_value_is_copied), TEST_CASE(test_struct_element), - TEST_CASE(test_zero_size_element), + TEST_CASE(test_alignment_of_payload), + TEST_CASE(test_mutate_in_place), + TEST_CASE(test_index_out_of_range), TEST_CASE(test_pop_all), - TEST_CASE(test_append_after_popping_last), - TEST_CASE(test_node_ownership_transfer), - TEST_CASE(test_null_arguments_are_rejected), + TEST_CASE(test_pop_with_wrong_size_keeps_element), + TEST_CASE(test_drop), + TEST_CASE(test_append_after_removing_last), + TEST_CASE(test_clear_leaves_list_usable), + TEST_CASE(test_mixed_element_types), + TEST_CASE(test_bad_arguments_are_rejected), TEST_CASE(test_append_literal_macro), }; From 83215d89b62b156ca1a84eeb556a1ae213369f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 9 Aug 2026 15:24:56 +0200 Subject: [PATCH 4/5] feat: cross-compilation support, driven by the compiler's target triple The Makefile decided the shared-library extension and the runtime-name flag from `uname -s`, which describes the machine running make. That is the wrong question the moment you cross-compile: building for Linux from macOS emitted a file named liblinkedlist.dylib linked with -install_name instead of a .so with a -soname. Everything now keys off `$(CC) -dumpmachine`, which every GCC and Clang answers with its actual target triple. make CROSS_COMPILE=aarch64-linux-gnu- make CROSS_COMPILE=arm-none-eabi- static make CROSS_COMPILE=aarch64-linux-gnu- CC=aarch64-linux-gnu-gcc-14 CROSS_COMPILE follows the kernel convention, trailing dash included, and only supplies defaults for CC and AR -- an explicit CC= still wins, which is what the third form relies on, since Ubuntu ships the C23-capable cross compiler version-suffixed. AR needed the same $(origin) test as CC: make defines it by default, so `AR ?=` never fired and a cross build would have used the host ar. Target handling: darwin -> .dylib with -install_name @rpath/... linux -> .so with -soname other -> static archive only. `make build` drops to static, and `make shared` refuses with an explanation rather than guessing an extension and a soname flag that would be confidently wrong for arm-none-eabi. Running the results: - RUNNER prefixes the test binary, so `RUNNER="qemu-aarch64 -L /usr/aarch64- linux-gnu"` runs a cross build. It is a plain prefix, so ssh or docker wrappers work too. - `make test` on a cross build with no RUNNER now explains itself instead of failing with "cannot execute binary file". - `make test-build` builds the tests without running them. - `make memcheck` refuses on a cross build. leaks and valgrind inspect a live process on this machine and have nothing to say about a foreign binary. - New `make toolchain` prints host, CC, AR, target triple, target OS, cross status, runner and shared-library name, so a misdetection is one command away rather than a puzzle. CI gains a cross-compile job: builds for aarch64-linux-gnu with gcc-14, asserts with file(1) that the output really is aarch64 ELF, and runs the whole suite under qemu-user. Verified locally against stub toolchains that report aarch64-unknown-linux-gnu and arm-none-eabi, covering the naming, the soname flag, the static fallback, both refusal paths, the RUNNER path, and CC-over-CROSS_COMPILE precedence. Native gcc-16 and clang builds, sanitizers, leaks and install are unchanged. --- .github/workflows/c-build.yml | 33 ++++++++ Makefile | 144 +++++++++++++++++++++++++++++----- README.md | 101 ++++++++++++++++++++++++ 3 files changed, 260 insertions(+), 18 deletions(-) diff --git a/.github/workflows/c-build.yml b/.github/workflows/c-build.yml index 2117852..d47045a 100644 --- a/.github/workflows/c-build.yml +++ b/.github/workflows/c-build.yml @@ -77,6 +77,39 @@ jobs: test -f staging/include/linkedlist.h make uninstall PREFIX="$PWD/staging" + cross: + name: cross-compile (aarch64-linux-gnu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # gcc-14-* rather than the unversioned gcc-*: the default cross compiler + # on ubuntu-24.04 is GCC 13, which predates -std=c23. + - name: Install cross toolchain and emulator + run: | + sudo apt-get update -y + sudo apt-get install -y gcc-14-aarch64-linux-gnu qemu-user-static + + # CROSS_COMPILE supplies the ar prefix; CC overrides just the compiler, + # because the package installs a version-suffixed binary. + - name: Show resolved toolchain + run: make toolchain CROSS_COMPILE=aarch64-linux-gnu- CC=aarch64-linux-gnu-gcc-14 + + - name: Build for aarch64 + run: make build CROSS_COMPILE=aarch64-linux-gnu- CC=aarch64-linux-gnu-gcc-14 + + # Proves the target detection actually took effect: a .so with an ELF + # aarch64 header, not a host-shaped artifact with the wrong name. + - name: Verify the artifacts are aarch64 ELF + run: | + file build/lib/liblinkedlist.so build/lib/liblinkedlist.a + file build/lib/liblinkedlist.so | grep -q 'ELF 64-bit LSB shared object, ARM aarch64' + + - name: Run the test suite under qemu + run: | + make test CROSS_COMPILE=aarch64-linux-gnu- CC=aarch64-linux-gnu-gcc-14 \ + RUNNER="qemu-aarch64-static -L /usr/aarch64-linux-gnu" + memcheck: name: valgrind runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index a6021a5..8af1139 100644 --- a/Makefile +++ b/Makefile @@ -22,21 +22,36 @@ TEST_APP := test_$(LIB_NAME) # Toolchain # --------------------------------------------------------------------------- -UNAME_S := $(shell uname -s) +# HOST is the machine running make. TARGET is what the compiler emits. They +# are the same thing right up until you cross-compile, and then conflating +# them is how you get a Linux shared object named liblinkedlist.dylib. +HOST_UNAME := $(shell uname -s) -ifeq ($(filter Darwin Linux,$(UNAME_S)),) - $(error Unsupported OS "$(UNAME_S)". This Makefile targets macOS and Linux.) +ifeq ($(filter Darwin Linux,$(HOST_UNAME)),) + $(error Unsupported build host "$(HOST_UNAME)". Build from macOS or Linux.) endif +# CROSS_COMPILE is the toolchain prefix, spelled the way the Linux kernel and +# buildroot spell it -- note the trailing dash: +# +# make CROSS_COMPILE=aarch64-linux-gnu- +# make CROSS_COMPILE=arm-none-eabi- static +# +# It only supplies defaults. An explicit `make CC=...` still wins, because a +# command-line variable overrides any assignment in the makefile. +CROSS_COMPILE ?= + # -std=c23 needs GCC >= 14 or Clang >= 18. Apple Clang 16+ accepts it too, but # it still lacks [[reproducible]] / [[unsequenced]], which the header # feature-detects with __has_c_attribute rather than assuming. # -# make always defines CC (to "cc"), so `?=` would never fire. Testing $(origin) -# instead means an explicit `make CC=clang` or `CC=clang make` still wins, and -# we only pick a default when nobody asked for anything. +# make always defines CC (to "cc") and AR (to "ar"), so `?=` would never fire +# for either. Testing $(origin) instead means an explicit `make CC=clang` or +# `CC=clang make` still wins, and we only pick a default when nobody asked. ifeq ($(origin CC),default) - ifeq ($(UNAME_S),Darwin) + ifneq ($(CROSS_COMPILE),) + CC := $(CROSS_COMPILE)gcc + else ifeq ($(HOST_UNAME),Darwin) # Newest Homebrew GCC available, e.g. gcc-16. Apple Clang is the fallback. BREW_PREFIX := $(shell brew --prefix 2>/dev/null || echo /opt/homebrew) HOMEBREW_GCC := $(firstword $(shell ls $(BREW_PREFIX)/bin/gcc-[0-9]* 2>/dev/null | sort -Vr)) @@ -46,13 +61,50 @@ ifeq ($(origin CC),default) endif endif -AR ?= ar +ifeq ($(origin AR),default) + AR := $(CROSS_COMPILE)ar +endif + DOXYGEN ?= doxygen FORMAT ?= clang-format -# leaks ships with macOS; valgrind is a package on Linux. Both are only needed -# by `make memcheck`, so a missing one is reported there, not here. -ifeq ($(UNAME_S),Darwin) +# Ask the compiler what it targets rather than asking the kernel what we are +# running on. Every GCC and Clang answers -dumpmachine with its target triple: +# aarch64-apple-darwin25, x86_64-pc-linux-gnu, arm-none-eabi, and so on. +TARGET_TRIPLE := $(shell $(CC) -dumpmachine 2>/dev/null) + +ifeq ($(TARGET_TRIPLE),) + $(error Could not run "$(CC) -dumpmachine". Is $(CC) installed and on PATH?) +endif + +ifneq ($(findstring darwin,$(TARGET_TRIPLE)),) + TARGET_OS := darwin +else ifneq ($(findstring linux,$(TARGET_TRIPLE)),) + TARGET_OS := linux +else + # Bare-metal (arm-none-eabi), BSDs, mingw, wasi... Static archives are fine; + # the shared-library rule refuses rather than guessing an extension and a + # soname flag that would silently be wrong. + TARGET_OS := other +endif + +# Cross-compiling if the prefix says so, or if the compiler targets an OS the +# host is not. Same-OS/different-arch cross builds are not detected -- they +# usually run under Rosetta or qemu anyway, and RUNNER covers that case. +HOST_OS := $(if $(filter Darwin,$(HOST_UNAME)),darwin,linux) +CROSS := $(if $(CROSS_COMPILE),1,$(if $(filter $(TARGET_OS),$(HOST_OS)),,1)) + +# How to launch a target binary on this host. Empty means "run it directly". +# For a cross build, point it at an emulator: +# +# make test CROSS_COMPILE=aarch64-linux-gnu- \ +# RUNNER="qemu-aarch64 -L /usr/aarch64-linux-gnu" +RUNNER ?= + +# leaks ships with macOS; valgrind is a package on Linux. Both inspect a +# process on *this* machine, so they follow the host, not the target -- and +# `make memcheck` refuses outright on a cross build. +ifeq ($(HOST_UNAME),Darwin) MEMCHECK ?= leaks MEMCHECK_ARGS ?= --atExit -- else @@ -82,9 +134,11 @@ LIB_DIR := $(BUILD)/lib DOCS_DIR := $(BUILD)/docs DOCS_HTML := $(DOCS_DIR)/html/index.html -# macOS and Linux disagree on both the extension and the flag that records a -# library's runtime name inside it. -ifeq ($(UNAME_S),Darwin) +# Mach-O and ELF disagree on both the extension and the flag that records a +# library's runtime name inside it. This keys off TARGET_OS, not the host: the +# whole point is that cross-compiling to Linux from a Mac produces a .so with +# a soname, not a .dylib with an install name. +ifeq ($(TARGET_OS),darwin) SHARED_EXT := dylib SHARED_NAME := lib$(LIB_NAME).$(SHARED_EXT) SONAME_FLAG := -Wl,-install_name,@rpath/$(SHARED_NAME) @@ -161,11 +215,30 @@ all: build ## Build the static and shared libraries ##@ Build .PHONY: build static shared -build: static shared ## Build both library flavors + +# A target with no shared-library convention (bare metal, say) still gets a +# perfectly good archive, so `build` drops down to static rather than failing. +ifeq ($(TARGET_OS),other) + BUILD_ARTIFACTS := static +else + BUILD_ARTIFACTS := static shared +endif + +build: $(BUILD_ARTIFACTS) ## Build both library flavors (static only on exotic targets) static: $(STATIC_LIB) ## Build the static library only +ifeq ($(TARGET_OS),other) +# Refuses rather than guessing. There is no right answer for the extension or +# the soname flag here, and a .so built with -soname for arm-none-eabi would +# be confidently wrong. +shared: ## Build the shared library only + @echo "No shared-library rule for target \"$(TARGET_TRIPLE)\"." + @echo "Use 'make static', or add a TARGET_OS case to the Makefile." + @exit 1 +else shared: $(SHARED_LIB) ## Build the shared library only +endif # Each recipe creates its own output directory with `mkdir -p $(@D)` instead # of depending on a directory target. Directory targets would be tidier, but @@ -202,21 +275,43 @@ $(TEST_OBJ_DIR)/%.o: $(TEST_DIR)/%.c $(CC) $(CFLAGS) -c $< -o $@ ##@ Test -.PHONY: test sanitize memcheck +.PHONY: test test-build sanitize memcheck + +# Build the tests without running them. This is the useful half of `test` on a +# cross build: it still type-checks and links everything for the target. +test-build: $(TEST_BIN) ## Build the test binary without running it + +# A cross-built binary will not run on this host. Without the guard the +# failure is "cannot execute binary file", which says nothing about why. +# RUNNER is the escape hatch: qemu-user, an ssh wrapper, whatever you have. test: $(TEST_BIN) ## Build and run the test suite - @echo "Running tests with $(notdir $(CC))..." - ./$(TEST_BIN) +ifeq ($(CROSS)$(RUNNER),1) + @echo "Cross-compiling for $(TARGET_TRIPLE) -- cannot run the tests here." + @echo "Built $(TEST_BIN). Set RUNNER to execute it, e.g." + @echo " make test CROSS_COMPILE=$(CROSS_COMPILE) RUNNER=\"qemu-aarch64 -L /usr/aarch64-linux-gnu\"" + @exit 1 +else + @echo "Running tests with $(notdir $(CC))$(if $(RUNNER), under $(firstword $(RUNNER)),)..." + $(RUNNER) ./$(TEST_BIN) +endif # Recursive make so the sanitizer flags are set before any object is compiled. # SANITIZE=1 also redirects BUILD, so this never clobbers the normal build. sanitize: ## Run the tests under AddressSanitizer + UndefinedBehaviorSanitizer @$(MAKE) --no-print-directory test SANITIZE=1 +# leaks and valgrind inspect a live process on this machine, so neither has +# anything to say about a binary built for another architecture. memcheck: $(TEST_BIN) ## Run the tests under leaks (macOS) / valgrind (Linux) +ifeq ($(CROSS),1) + @echo "memcheck runs a host process; $(TARGET_TRIPLE) binaries cannot be inspected here." + @exit 1 +else @command -v $(MEMCHECK) >/dev/null 2>&1 || { \ echo "$(MEMCHECK) not found. Install it first."; exit 1; \ } $(MEMCHECK) $(MEMCHECK_ARGS) ./$(TEST_BIN) +endif ##@ Docs .PHONY: docs docs-open @@ -262,6 +357,19 @@ clean: ## Remove every build artifact @rm -rf build ##@ Help +.PHONY: toolchain +toolchain: ## Show what the build resolved: compiler, target, cross status + @printf ' %-16s %s\n' \ + 'host' '$(HOST_UNAME) ($(HOST_OS))' \ + 'CC' '$(CC)' \ + 'AR' '$(AR)' \ + 'target' '$(TARGET_TRIPLE)' \ + 'target OS' '$(TARGET_OS)' \ + 'cross' '$(if $(CROSS),yes,no)' \ + 'runner' '$(if $(RUNNER),$(RUNNER),)' \ + 'shared lib' '$(if $(filter other,$(TARGET_OS)),,$(SHARED_NAME))' \ + 'build dir' '$(BUILD)' + .PHONY: help help: ## Display this help @awk 'BEGIN {FS = ":.*##"; \ diff --git a/README.md b/README.md index beecb64..755875f 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ are cloning this for. - [📋 Requirements](#-requirements) - [⚡ Quick start](#-quick-start) - [🧰 The Makefile, in full](#-the-makefile-in-full) +- [🌍 Cross-compiling](#-cross-compiling) - [📚 Using the library](#-using-the-library) - [🗂️ Project layout](#️-project-layout) - [🧑‍💻 Editor setup](#-editor-setup) @@ -50,6 +51,7 @@ are cloning this for. | 📖 **Docs** | Doxygen, generated into `build/docs` | | 🧑‍💻 **Editor that understands C23** | clangd-driven IntelliSense, because cpptools' engine cannot parse C23 attributes | | 🤖 **CI across three compilers** | GCC 14, Clang 18, and Apple Clang | +| 🌍 **Cross-compilation** | `CROSS_COMPILE=` prefix, target detected from the compiler, `RUNNER=` for qemu | | 📥 **Installable** | `make install` honouring `PREFIX` and `DESTDIR` | --- @@ -248,6 +250,105 @@ make sanitize > makefile. The project flags are appended with `override` specifically to > prevent that. It is a trap worth knowing about in any Makefile you write. +### 🌍 Cross-compiling + +Yes — with any GCC or Clang cross toolchain. Use `CROSS_COMPILE`, spelled the +way the Linux kernel and buildroot spell it, **trailing dash included**: + +```bash +# 🐧 Linux/arm64 from anywhere +make CROSS_COMPILE=aarch64-linux-gnu- + +# 🔩 Bare metal — static archive only +make CROSS_COMPILE=arm-none-eabi- static + +# 🎯 Prefix for the binutils, explicit CC for a version-suffixed compiler +make CROSS_COMPILE=aarch64-linux-gnu- CC=aarch64-linux-gnu-gcc-14 +``` + +`CROSS_COMPILE` only supplies *defaults* for `CC` and `AR`, so an explicit +`CC=` on the command line still wins — that last example is exactly what CI +does, because Ubuntu ships the C23-capable cross compiler as +`aarch64-linux-gnu-gcc-14`. + +#### 🔎 Check what got resolved + +```bash +make toolchain +``` + +```text + host Darwin (darwin) + CC aarch64-linux-gnu-gcc + AR aarch64-linux-gnu-ar + target aarch64-unknown-linux-gnu + target OS linux + cross yes + runner + shared lib liblinkedlist.so + build dir build +``` + +#### 🧭 How the target is detected + +The Makefile asks **the compiler**, not the kernel: + +```make +TARGET_TRIPLE := $(shell $(CC) -dumpmachine) +``` + +> 🐛 This used to be `uname -s`, which describes the machine running `make` — +> the wrong answer the moment you cross-compile. Building for Linux from a Mac +> produced a file called `liblinkedlist.dylib` linked with `-install_name` +> instead of a `.so` with a `-soname`. Every GCC and Clang answers +> `-dumpmachine` with its target triple, so that is the honest source. + +| Target triple contains | Shared library | Runtime-name flag | +| ---------------------- | --------------------- | ---------------------------- | +| `darwin` | `liblinkedlist.dylib` | `-Wl,-install_name,@rpath/…` | +| `linux` | `liblinkedlist.so` | `-Wl,-soname,…` | +| anything else | ❌ refused | — | + +Unrecognised targets (bare metal, mingw, wasi…) still build a perfectly good +static archive, and `make build` quietly drops to `static` for them. `make +shared` refuses with an explanation rather than guessing an extension and a +soname flag that would be confidently wrong. + +#### 🏃 Running cross-built tests + +A cross-built binary will not execute on your host, so `make test` stops with +an explanation instead of a bare "cannot execute binary file". Point `RUNNER` +at an emulator to actually run them: + +```bash +make test CROSS_COMPILE=aarch64-linux-gnu- \ + RUNNER="qemu-aarch64 -L /usr/aarch64-linux-gnu" +``` + +`RUNNER` is just a prefix, so an ssh or docker wrapper works the same way. To +build the tests without running them at all: + +```bash +make test-build CROSS_COMPILE=aarch64-linux-gnu- +``` + +`make memcheck` refuses on a cross build — `leaks` and `valgrind` inspect a +live process on *this* machine and have nothing to say about a foreign binary. + +#### 📦 Getting a toolchain + +```bash +# Debian / Ubuntu — Linux/arm64 +sudo apt-get install gcc-14-aarch64-linux-gnu qemu-user-static + +# macOS — bare metal targets +brew install aarch64-elf-gcc arm-none-eabi-gcc +``` + +> 🍎 Targeting **Linux from macOS** needs a full sysroot with glibc, which +> Homebrew does not provide. Use Docker, crosstool-NG, or Zig (`zig cc -target +> aarch64-linux-gnu`) for that combination. + ### 🏗️ How the build tree is laid out ```text From 417bd17a2372fb319261a1377371e213c38bb26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 9 Aug 2026 15:32:03 +0200 Subject: [PATCH 5/5] fix(ci): correct three toolchain assumptions the first CI run disproved Every failure was a wrong claim in this branch, not a flaky runner. GCC 14 was rejected by its own version guard -------------------------------------------- GCC 14.2 with -std=c23 reports __STDC_VERSION__ as the draft 202000L, not 202311L -- it shipped before C23 was published and GCC only bumped the macro in 15. The guard tested `>= 202311L` and so refused the newest compiler in Ubuntu 24.04 LTS, which took out the gcc-14, valgrind and cross-compile jobs. The guard now accepts GCC >= 14 at 202000L as a second, deliberately narrow clause: 202000L is also what GCC 13 reports for -std=c2x, and GCC 13 has neither constexpr nor [[reproducible]], so widening it further would produce exactly the error cascade the guard exists to prevent. Verified against an eight-case truth table covering GCC 13/14/15/16, Clang 17/18, and a missing __STDC_VERSION__. Clang 18 has -std=c23 but not C23 constexpr ------------------------------------------- The mirror image: Clang 18 reports the full 202311L and still fails on `constexpr size_t ITEM_COUNT = 10;`. That arrived in Clang 19. The stated minimum is Clang 19 now, and CI installs it from apt.llvm.org since Ubuntu 24.04 does not carry it. A version macro is evidence, not proof. clang-format version drift -------------------------- CI ran clang-format-18 against a tree formatted with 22, and the two disagree about where to break a call. Pinned to clang-format==22.1.8 from PyPI, which is the same binary on every platform and matches what the tree already uses, so no reformatting was needed. Contributors can match it exactly with `pipx install clang-format==22.1.8`. README and docs/C23.md updated: the requirements tables, the CI description, the quoted guard, and a note on both version-macro traps. --- .github/workflows/c-build.yml | 41 +++++++++++++++++++++++++---------- README.md | 30 ++++++++++++++++++++----- docs/C23.md | 22 ++++++++++++++----- include/linkedlist.h | 21 ++++++++++++++++-- 4 files changed, 89 insertions(+), 25 deletions(-) diff --git a/.github/workflows/c-build.yml b/.github/workflows/c-build.yml index d47045a..a6d80eb 100644 --- a/.github/workflows/c-build.yml +++ b/.github/workflows/c-build.yml @@ -21,10 +21,21 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install clang-format - run: sudo apt-get update -y && sudo apt-get install -y clang-format-18 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + # Pinned to an exact version on purpose. clang-format's output changes + # between major releases, so an unpinned binary makes this check depend + # on whatever the runner happens to ship -- CI ran 18 while the tree had + # been formatted with 22, and the two disagreed. The pip package is the + # same binary on every platform, so contributors can match it exactly: + # pipx install clang-format==22.1.8 + - name: Install a pinned clang-format + run: pip install clang-format==22.1.8 + - name: Check formatting - run: make format-check FORMAT=clang-format-18 + run: make format-check test: name: test (${{ matrix.os }}, ${{ matrix.cc }}) @@ -33,27 +44,33 @@ jobs: fail-fast: false matrix: include: - # -std=c23 needs GCC >= 14 or Clang >= 18; both ship in the - # ubuntu-24.04 image that ubuntu-latest points at. Older toolchains - # only know the -std=c2x draft spelling and lack [[reproducible]]. + # GCC 14 is the oldest GCC that builds this. Note it still reports + # __STDC_VERSION__ as the draft 202000L under -std=c23 -- GCC only + # bumped the macro in 15 -- which the header's guard allows for. - os: ubuntu-latest cc: gcc-14 - packages: gcc-14 + setup: sudo apt-get update -y && sudo apt-get install -y gcc-14 + # Clang 19, not 18. Clang 18 accepts -std=c23 but has no C23 + # `constexpr`, which the test suite uses. It is not in the Ubuntu + # 24.04 archive, hence apt.llvm.org. - os: ubuntu-latest - cc: clang-18 - packages: clang-18 + cc: clang-19 + setup: | + wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh + chmod +x /tmp/llvm.sh + sudo /tmp/llvm.sh 19 # Apple Clang has C23 but not [[reproducible]] / [[unsequenced]], # so this leg is what proves the __has_c_attribute guards in # include/linkedlist.h actually work. - os: macos-latest cc: cc - packages: "" + setup: "" steps: - uses: actions/checkout@v4 - name: Install compiler - if: matrix.packages != '' - run: sudo apt-get update -y && sudo apt-get install -y ${{ matrix.packages }} + if: matrix.setup != '' + run: ${{ matrix.setup }} - name: Check versions run: | diff --git a/README.md b/README.md index 755875f..682f187 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ are cloning this for. | 🖊️ **One formatting truth** | `.clang-format`, enforced by CI | | 📖 **Docs** | Doxygen, generated into `build/docs` | | 🧑‍💻 **Editor that understands C23** | clangd-driven IntelliSense, because cpptools' engine cannot parse C23 attributes | -| 🤖 **CI across three compilers** | GCC 14, Clang 18, and Apple Clang | +| 🤖 **CI across three compilers** | GCC 14, Clang 19, and Apple Clang | | 🌍 **Cross-compilation** | `CROSS_COMPILE=` prefix, target detected from the compiler, `RUNNER=` for qemu | | 📥 **Installable** | `make install` honouring `PREFIX` and `DESTDIR` | @@ -115,11 +115,22 @@ compiler support matrix measured on real toolchains, the `reproducible` vs standard, rather than burying you in syntax errors on `nullptr`: ```c -#if !defined(__STDC_VERSION__) || __STDC_VERSION__ < 202311L -#error "linkedlist.h requires C23. Compile with -std=c23 (GCC >= 14, Clang >= 18)." +#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L +// C23 as published. Nothing to do. +#elif defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && \ + defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202000L +// GCC 14 in -std=c23 mode: the macro says draft, the compiler says C23. +#else +#error "linkedlist.h requires C23. Compile with -std=c23 (GCC >= 14, Clang >= 19)." #endif ``` +> 🪤 **Version macros lie in both directions**, which is why this is not a +> one-liner. **GCC 14** implements everything here but still reports the draft +> `__STDC_VERSION__` of `202000L`, because it shipped before C23 was published — +> GCC bumped it in 15. Meanwhile **Clang 18** reports the full `202311L` and yet +> has no C23 `constexpr`; that landed in Clang 19. CI caught both. + --- ## 📋 Requirements @@ -139,13 +150,15 @@ standard, rather than burying you in syntax errors on `nullptr`: **macOS** 🍎 ```bash -brew install gcc clang-format doxygen +brew install gcc doxygen +pipx install clang-format==22.1.8 ``` **Debian / Ubuntu** 🐧 ```bash -sudo apt-get install gcc-14 clang-format doxygen valgrind +sudo apt-get install gcc-14 doxygen valgrind +pipx install clang-format==22.1.8 ``` --- @@ -608,6 +621,11 @@ make format-check # 🔍 fail if anything is unformatted (CI runs this) Rules live in [.clang-format](.clang-format) — LLVM style, 2-space indent, 100-column limit. VS Code formats on save via clangd. +> 📌 **The version is pinned.** clang-format's output changes between major +> releases, so CI installs exactly `clang-format==22.1.8` from PyPI. Match it +> locally with `pipx install clang-format==22.1.8` (the pip package is the same +> binary on every platform), or expect `make format` to fight with CI. + --- ## 📖 API documentation @@ -695,7 +713,7 @@ make sanitize # 🧹 clean under ASan + UBSan make memcheck # 💧 no leaks ``` -CI runs all four across GCC 14, Clang 18, and Apple Clang, so it is worth +CI runs all four across GCC 14, Clang 19, and Apple Clang, so it is worth checking at least two compilers locally: ```bash diff --git a/docs/C23.md b/docs/C23.md index 4e4aa1f..3393612 100644 --- a/docs/C23.md +++ b/docs/C23.md @@ -15,14 +15,26 @@ again for the cpptools debugger. All three have to agree. | Toolchain | First release with usable C23 | Notes | | ------------ | ----------------------------- | ------------------------------------------------------- | -| GCC | 14 (15+ recommended) | Only compiler with `[[reproducible]]`/`[[unsequenced]]` | -| Clang / LLVM | 18 | `-std=c23` accepted from 18 | +| GCC | 14 | Only compiler with `[[reproducible]]`/`[[unsequenced]]` | +| Clang / LLVM | 19 | 18 accepts `-std=c23` but has no C23 `constexpr` | | Apple Clang | Xcode 16 | Follows upstream Clang; no `[[reproducible]]` | | MSVC | partial | `/std:clatest`; not targeted here | -[include/linkedlist.h](../include/linkedlist.h) opens with an `#error` on -`__STDC_VERSION__ < 202311L` so a pre-C23 compiler says so plainly instead of -emitting a wall of syntax errors on `nullptr`. +[include/linkedlist.h](../include/linkedlist.h) opens with a guard so a +pre-C23 compiler says so plainly instead of emitting a wall of syntax errors on +`nullptr`. It cannot simply test `__STDC_VERSION__ >= 202311L`, though: + +> ⚠️ **GCC 14 reports the draft value.** It shipped before C23 was published, so +> `-std=c23` there defines `__STDC_VERSION__` as `202000L` while implementing +> everything this library uses. GCC bumped the macro to `202311L` in 15. +> Requiring `202311L` would reject the newest compiler in Ubuntu 24.04 LTS, so +> the guard adds a narrow `__GNUC__ >= 14` clause. It stays narrow because +> `202000L` is also what GCC 13 reports for `-std=c2x`, and GCC 13 has neither +> `constexpr` nor `[[reproducible]]`. + +The mirror-image trap is Clang: **Clang 18 accepts `-std=c23` and reports +`202311L`, but has no C23 `constexpr`** — that arrived in Clang 19. A version +macro is evidence, not proof; `__has_c_attribute` and a real compile are. ## Attributes diff --git a/include/linkedlist.h b/include/linkedlist.h index 95edf3c..05d0b60 100644 --- a/include/linkedlist.h +++ b/include/linkedlist.h @@ -53,8 +53,25 @@ // A hard error beats the cascade of confusing syntax errors a pre-C23 // compiler would otherwise produce on `nullptr` and `[[nodiscard]]`. -#if !defined(__STDC_VERSION__) || __STDC_VERSION__ < 202311L -#error "linkedlist.h requires C23. Compile with -std=c23 (GCC >= 14, Clang >= 18)." +// +// Testing `__STDC_VERSION__ >= 202311L` alone is too strict. GCC 14 shipped +// before C23 was published, so `-std=c23` there still reports the draft value +// 202000L even though it implements everything this header uses; GCC only +// bumped the macro in 15. Rejecting it would rule out the compiler that +// Ubuntu 24.04 LTS ships as its newest, so GCC 14 gets an explicit pass. +// +// The second clause is narrow on purpose. 202000L is also what GCC 13 reports +// for -std=c2x, and GCC 13 has neither `constexpr` nor `[[reproducible]]`, so +// letting it through would produce exactly the cascade this guard prevents. +// Clang defines __GNUC__ as 4 for compatibility, hence excluding it here -- +// Clang 18+ reports 202311L and takes the first branch. +#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L +// C23 as published. Nothing to do. +#elif defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__STDC_VERSION__) && \ + __STDC_VERSION__ >= 202000L +// GCC 14 in -std=c23 mode: the macro says draft, the compiler says C23. +#else +#error "linkedlist.h requires C23. Compile with -std=c23 (GCC >= 14, Clang >= 19)." #endif // size_t only. C23 also puts nullptr_t and unreachable() here, but