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..a6d80eb 100644 --- a/.github/workflows/c-build.yml +++ b/.github/workflows/c-build.yml @@ -2,64 +2,154 @@ 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: + format: + name: clang-format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - 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 + test: + name: test (${{ matrix.os }}, ${{ matrix.cc }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # 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 + 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-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 + setup: "" + steps: + - uses: actions/checkout@v4 + + - name: Install compiler + if: matrix.setup != '' + run: ${{ matrix.setup }} + + - name: Check versions + run: | + ${{ matrix.cc }} --version + make --version | head -1 + + - name: Build libraries + run: make build CC=${{ matrix.cc }} + + - name: Run tests + run: make test CC=${{ matrix.cc }} + + - name: Run tests under ASan + UBSan + run: make sanitize CC=${{ matrix.cc }} + + # 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" + + cross: + name: cross-compile (aarch64-linux-gnu) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - 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 + # 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 - - name: Set up gcc-13 - run: | - sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-13 90 + # 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: Check versions - run: | - gcc --version - make --version + - name: Build for aarch64 + run: make build CROSS_COMPILE=aarch64-linux-gnu- CC=aarch64-linux-gnu-gcc-14 - - name: make build - run: make build + # 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: make test - run: make test + - 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" - - name: make memcheck - run: make memcheck + memcheck: + name: valgrind + runs-on: ubuntu-latest + steps: + - 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 - build: + docs: + name: doxygen runs-on: ubuntu-latest - needs: test - if: github.event_name == 'push' && github.ref == 'refs/heads/main' 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: 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 + - 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..8af1139 100644 --- a/Makefile +++ b/Makefile @@ -1,119 +1,383 @@ -.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 +# 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) -# Determine OS -UNAME_S := $(shell uname -s) +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 ?= -ifeq ($(UNAME_S),Darwin) - MEMCHECK = $(MEMCHECK_MACOS) - MEMCHECK_ARGS = --atExit -- - CC = $(CC_MACOS) - AR = $(AR_MACOS) +# -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") 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) + 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)) + CC := $(if $(HOMEBREW_GCC),$(HOMEBREW_GCC),cc) + else + CC := gcc + endif endif -ifeq ($(UNAME_S),Linux) - MEMCHECK = $(MEMCHECK_LINUX) - MEMCHECK_ARGS = - CC = $(CC_LINUX) - AR = $(AR_LINUX) + +ifeq ($(origin AR),default) + AR := $(CROSS_COMPILE)ar endif -# Check if OS is supported -ifneq ($(UNAME_S),Darwin) - ifneq ($(UNAME_S),Linux) - $(error "Unsupported OS") - endif +DOXYGEN ?= doxygen +FORMAT ?= clang-format + +# 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 -# 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))) +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 -# 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) +# 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)) -SRC_DIR := src -OBJ_DIR := obj -LIB_DIR := lib +# 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 ?= -TEST_SRC_DIR := tests -TEST_OBJ_DIR := obj -BUILD_DIR := build +# 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 + MEMCHECK ?= valgrind + MEMCHECK_ARGS ?= --leak-check=full --show-leak-kinds=all --error-exitcode=1 +endif + +# --------------------------------------------------------------------------- +# Layout +# --------------------------------------------------------------------------- -SRC_FILES = $(wildcard $(SRC_DIR)/*.c) -OBJ_FILES = $(SRC_FILES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o) +SRC_DIR := src +INC_DIR := include +TEST_DIR := tests -TEST_FILES = $(wildcard $(TEST_SRC_DIR)/*.c) -TEST_OBJS = $(TEST_FILES:$(TEST_SRC_DIR)/%.c=$(TEST_OBJ_DIR)/%.o) +# 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 -INCLUDE_DIRS = -Iinclude +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 + +# 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) +else + SHARED_EXT := so + SHARED_NAME := lib$(LIB_NAME).$(SHARED_EXT) + SONAME_FLAG := -Wl,-soname,$(SHARED_NAME) +endif + +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)) + +# -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) + +FORMAT_FILES := $(SRCS) $(TEST_SRCS) $(wildcard $(INC_DIR)/*.h) + +# --------------------------------------------------------------------------- +# Flags +# --------------------------------------------------------------------------- + +# -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 is yours to set -- `make CFLAGS="-O0 -g3"` swaps the optimisation +# level and nothing else. +CFLAGS ?= -O2 -g + +# `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. Same `override` reason. + SAN_FLAGS := -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all + override CFLAGS += $(SAN_FLAGS) + override LDFLAGS += $(SAN_FLAGS) +endif -# Targets -##@ Default target +# Install locations, used by `make install`. +PREFIX ?= /usr/local +DESTDIR ?= +INCLUDEDIR ?= $(PREFIX)/include +LIBDIR ?= $(PREFIX)/lib + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- + +##@ Default .PHONY: all -all: clean build ## Clean and build the library +all: build ## Build the static and shared libraries + +##@ Build +.PHONY: build static shared + +# 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 +# $(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) -##@ Build commands -.PHONY: clean build -build: $(TARGET_LIB) ## Clean and build the library +# 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 $@ $^ -$(TARGET_LIB): $(OBJ_FILES) | $(LIB_DIR) - $(CC) $(LDFLAGS) -o $(LIB_DIR)/$@ $^ +$(SHARED_LIB): $(OBJS) + $(MKDIR) + $(CC) -shared $(LDFLAGS) -o $@ $^ $(LDLIBS) $(SONAME_FLAG) -$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR) - $(CC) $(CFLAGS) $(INCLUDE_DIRS) -c -o $@ $< +$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c + $(MKDIR) + $(CC) $(CFLAGS) -c $< -o $@ -$(TEST_APP): $(TEST_OBJS) $(LIB_DIR)/$(TARGET_LIB) | $(BUILD_DIR) - $(CC) $(CFLAGS) $(INCLUDE_DIRS) -o $(BUILD_DIR)/$@ $^ +# 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) -$(TEST_OBJ_DIR)/%.o: $(TEST_SRC_DIR)/%.c | $(TEST_OBJ_DIR) - $(CC) $(CFLAGS) $(INCLUDE_DIRS) -c -o $@ $< +$(TEST_OBJ_DIR)/%.o: $(TEST_DIR)/%.c + $(MKDIR) + $(CC) $(CFLAGS) -c $< -o $@ -$(BUILD_DIR): - @mkdir -p $(BUILD_DIR) +##@ Test +.PHONY: test test-build sanitize memcheck -$(OBJ_DIR): - @mkdir -p $(OBJ_DIR) +# 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 -$(LIB_DIR): - @mkdir -p $(LIB_DIR) +# 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 +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 +# 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)" -##@ Test commands -.PHONY: test -test: clean build $(TEST_APP) ## Run tests - @echo "Running tests..." - ./$(BUILD_DIR)/$(TEST_APP) +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) -.PHONY: memcheck -memcheck: test ## Run tests and check for memory leaks - @echo "Running tests with memory check..." - $(MEMCHECK) $(MEMCHECK_ARGS) ./$(BUILD_DIR)/$(TEST_APP) +##@ Format +.PHONY: format format-check +format: ## Rewrite all sources in place using .clang-format + $(FORMAT) -i $(FORMAT_FILES) -##@ Clean commands +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 +.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)' -##@ Help commands .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..682f187 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,728 @@ -# c-library-template +# ๐Ÿงฉ c-library-template -This is a template for a C library. +[![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) -It includes +> ๐Ÿš€ A batteries-included starting point for a **C23** library: build, tests, +> sanitizers, formatting, docs, editor, and CI already wired up. -- [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 +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. -## Usage +--- -To use this template, clone the repository and run the following commands: +## ๐Ÿ“‘ 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) +- [๐ŸŒ Cross-compiling](#-cross-compiling) +- [๐Ÿ“š 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 19, and Apple Clang | +| ๐ŸŒ **Cross-compilation** | `CROSS_COMPILE=` prefix, target detected from the compiler, `RUNNER=` for qemu | +| ๐Ÿ“ฅ **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 +// 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 + +| 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 doxygen +pipx install clang-format==22.1.8 +``` + +**Debian / Ubuntu** ๐Ÿง + +```bash +sudo apt-get install gcc-14 doxygen valgrind +pipx install clang-format==22.1.8 +``` + +--- + +## โšก Quick start + +```bash +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 +``` + +Expected output from `make test`: + +```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 +``` + +> โš ๏ธ `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. + +### ๐ŸŒ 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 +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 generated lives under `build/`, so `.gitignore` needs one entry and +`make clean` is a single `rm -rf`. + +**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. + +### ๐Ÿง  Dependency tracking + +`-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". + +### โž• Adding source files + +Just create them. `src/*.c` and `tests/*.c` are globbed, so a new file is +picked up with no Makefile change. + +--- + +## ๐Ÿ“š Using the library + +### ๐ŸŽฌ A complete example + +```c +#include +#include + +int main(void) { + List *list = list_new(); + if (list == nullptr) { + 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(list, &value, sizeof value)) { + list_destroy(list); + return 1; + } + + // ๐Ÿช„ Or skip the temporary entirely โ€” typeof works the size out. + if (!LIST_APPEND_LITERAL(list, 3.5)) { + list_destroy(list); + return 1; + } + + // ๐Ÿ” 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 the list and everything still in it +} +``` + +Compile against it: ```bash -make -make test +# 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 ``` -## License +### ๐Ÿ“– API at a glance + +**Lifecycle** ๐Ÿ”„ + +| 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 | + +**Insertion** โž• โ€” both **copy** `size` bytes out of `value` + +| 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 | + +**Removal** โž– + +| 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 rules + +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. + +### ๐Ÿ”’ Why `List` and `Node` are opaque + +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. + +> ๐Ÿ“Œ **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 + +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: + +| 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 19, and Apple Clang, so it is worth +checking at least two compilers locally: + +```bash +make clean && make test +make clean && make CC=clang test +``` + +--- + +## ๐Ÿ“„ 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..3393612 --- /dev/null +++ b/docs/C23.md @@ -0,0 +1,233 @@ +# 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 | 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 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 + +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 | 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: +`[[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 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. + +### 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 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` | `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. 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. +- **`_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. +- **`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..05d0b60 100644 --- a/include/linkedlist.h +++ b/include/linkedlist.h @@ -1,34 +1,366 @@ -#ifndef LINKEDLIST_H -#define LINKEDLIST_H - -#include - -typedef struct Node -{ - // size of the data type - size_t size; - void *data; - struct Node *next; -} Node; - -typedef struct List -{ - Node *head; - size_t size; - - // used to have a reference to the last node, but - // this is not a circular linked list - Node *tail; -} List; - -List *list_new(); +#pragma once + +/** + * \file linkedlist.h + * + * A singly linked list that stores a byte-wise copy of each element. + * + * \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. + * + * \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 +// compiler would otherwise produce on `nullptr` and `[[nodiscard]]`. +// +// 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 +// -- 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". + * + * 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) +#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 + +/** + * 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 List List; + +/** + * A read-only cursor to one element, used to walk a list. + * + * 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 Node Node; + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +/** + * 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 element in it. + * + * 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); -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 + +/** + * Remove every element, leaving an empty but still usable list. + * + * Passing `nullptr` is a no-op. + * + * \param list the list to empty. + */ +void list_clear(List *list); + +// --------------------------------------------------------------------------- +// Insertion +// --------------------------------------------------------------------------- + +/** + * Copy \p value in as the new first element. + * + * \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]] bool list_prepend(List *list, const void *value, size_t size); + +/** + * Copy \p value in as the new last element, in O(1). + * + * \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. + * + * \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; + +/** + * 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. + * + * Writing more than \p size bytes through it overruns the element. + * + * \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]] void *list_at_mut(List *list, size_t index, size_t *size); + +// --------------------------------------------------------------------------- +// Iteration +// --------------------------------------------------------------------------- + +/** + * Cursor to the first element. + * + * \param list the list to walk, or `nullptr`. + * \return a cursor, or `nullptr` if the list is empty or `nullptr`. + * + * \sa node_next + */ +[[nodiscard]] const Node *list_first(const List *list) LINKEDLIST_REPRODUCIBLE; + +/** + * Advance a cursor. + * + * \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 node the cursor, or `nullptr`. + * \return a pointer into the list's storage, or `nullptr` for a `nullptr` + * cursor. See \ref invalidation. + */ +[[nodiscard]] const void *node_value(const Node *node) LINKEDLIST_REPRODUCIBLE; + +/** + * Size in bytes of the element a cursor points at. + * + * \param node the cursor, or `nullptr`. + * \return the size, or 0 for a `nullptr` cursor. + */ +[[nodiscard]] size_t node_size(const Node *node) LINKEDLIST_REPRODUCIBLE; + +// --------------------------------------------------------------------------- +// Removal +// --------------------------------------------------------------------------- + +/** + * Copy the first element into \p out and remove it. + * + * \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. + * \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_drop + */ +[[nodiscard]] bool list_pop(List *list, void *out, size_t size); + +/** + * Remove the first element without copying it anywhere. + * + * \param list the list to drop from, or `nullptr`. + * \return `true` if an element was removed, `false` if the list was empty or + * `nullptr`. + * + * \sa list_pop + */ +[[nodiscard]] bool list_drop(List *list); + +/** + * \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 copies out of it before returning. + * + * \p expr is evaluated exactly once: `typeof` and `sizeof` do not evaluate + * their operands. + */ +#define LIST_APPEND_LITERAL(list, expr) \ + list_append((list), &(typeof(expr)){(expr)}, sizeof(typeof(expr))) diff --git a/src/linkedlist.c b/src/linkedlist.c index 0c8ac4d..7813577 100644 --- a/src/linkedlist.c +++ b/src/linkedlist.c @@ -1,138 +1,338 @@ -#include "../include/linkedlist.h" -#include +/** + * \file linkedlist.c + * + * Implementation of the list declared in include/linkedlist.h. + * + * 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. + * + * 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 + +/** + * 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[]; +}; + +/** + * `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; + } + + // 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; + } + + Node *node = malloc(total); + if (node == nullptr) { + return nullptr; + } + + node->next = nullptr; + node->size = size; -List *list_new() -{ - List *l = (List *)malloc(sizeof(List)); - l->head = NULL; - l->tail = NULL; - l->size = 0; + // 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 l; + return node; } -void list_destroy(List *list) -{ - if (list->head == NULL) - { - free(list); - 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; + 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; + } + + node->next = nullptr; + + return node; +} + +/** + * 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 *temp_node = NULL; + Node *node = list->head; + for (size_t i = 0; i < index; i++) { + node = node->next; + } + + return node; +} - while (list->head != NULL) - { - temp_node = list->head; - list->head = temp_node->next; +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- - list_node_destroy(temp_node); +List *list_new(void) { + List *list = malloc(sizeof(List)); + if (list == nullptr) { + return nullptr; } - free(list); + list->head = nullptr; + list->tail = nullptr; + list->count = 0; + + return list; } -void list_node_destroy(Node *node) -{ - free(node->data); - free(node); +void list_clear(List *list) { + if (list == nullptr) { + return; + } + + Node *node = list->head; + while (node != nullptr) { + // Read next before freeing, not after. + Node *next = node->next; + free(node); + node = next; + } + + list->head = nullptr; + list->tail = nullptr; + list->count = 0; } -void list_prepend(List *list, Node *node) -{ - if (list == NULL || node == NULL) - { +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; } - // store the pointer to the first element prepend to the list - // to keep track of the tail of the list - if (list->size == 0) - { + list_clear(list); + free(list); +} + +// --------------------------------------------------------------------------- +// 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; + } + + // 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++; + list->count++; + + return true; } -void list_append(List *list, Node *node) -{ - if (list == NULL || node == NULL) - { - return; +bool list_append(List *list, const void *value, size_t size) { + if (list == nullptr) { + return false; } - node->next = NULL; + Node *node = node_new(value, size); + if (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) - { + if (list->head == nullptr) { list->head = node; list->tail = node; + 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_clear would then walk forever. + return true; } - // add the node to the tail of the list list->tail->next = node; list->tail = node; + list->count++; - list->size++; + return true; } -size_t list_size(List *list) -{ - return list->size; +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +size_t list_size(const List *list) LINKEDLIST_REPRODUCIBLE { + return list == nullptr ? 0 : list->count; } -void list_prepend_value(List *list, void *value, size_t size) -{ - Node *node = malloc(sizeof(Node)); - node->next = NULL; +bool list_is_empty(const List *list) LINKEDLIST_REPRODUCIBLE { + return list_size(list) == 0; +} - node->data = malloc(size); - node->data = value; - node->size = size; +const void *list_at(const List *list, size_t index, size_t *size) { + const Node *node = node_at(list, index); + if (node == nullptr) { + return nullptr; + } + + if (size != nullptr) { + *size = node->size; + } - list_prepend(list, node); + return node->data; } -void list_append_value(List *list, void *value, size_t size) -{ - Node *node = malloc(sizeof(Node)); - node->next = NULL; +void *list_at_mut(List *list, size_t index, size_t *size) { + Node *node = node_at(list, index); + if (node == nullptr) { + return nullptr; + } - node->data = malloc(size); - node->data = value; - node->size = size; + if (size != nullptr) { + *size = node->size; + } - list_append(list, node); + return node->data; } -Node *list_pop(List *list) -{ - if (list == NULL || list->head == NULL) - { - return NULL; - } +// --------------------------------------------------------------------------- +// Iteration +// --------------------------------------------------------------------------- - Node *node = list->head; +const Node *list_first(const List *list) LINKEDLIST_REPRODUCIBLE { + return list == nullptr ? nullptr : list->head; +} + +const Node *node_next(const Node *node) LINKEDLIST_REPRODUCIBLE { + return node == nullptr ? nullptr : node->next; +} + +const void *node_value(const Node *node) LINKEDLIST_REPRODUCIBLE { + return node == nullptr ? nullptr : node->data; +} + +size_t node_size(const Node *node) LINKEDLIST_REPRODUCIBLE { + return node == nullptr ? 0 : node->size; +} - if (list->head->next != NULL) - { - list->head = list->head->next; - list->size--; +// --------------------------------------------------------------------------- +// Removal +// --------------------------------------------------------------------------- + +bool list_pop(List *list, void *out, size_t size) { + if (list == nullptr || out == nullptr || list->head == nullptr) { + return false; } - else // this is the last - { - list->head = NULL; - list->tail = NULL; - list->size = 0; + + // 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->next = NULL; + Node *node = unlink_head(list); + memcpy(out, node->data, size); + free(node); - return node; -} \ No newline at end of file + return true; +} + +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 19d1b24..2897b35 100644 --- a/tests/main.c +++ b/tests/main.c @@ -1,224 +1,538 @@ -#include "../include/linkedlist.h" -#include +/** + * \file main.c + * + * 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 + * by testing nothing. \ref CHECK is a real function call that always runs. + */ + +#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 -void test_list_new() -{ +/** + * 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); + } +} + +/** 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); +} + +[[nodiscard]] static List *new_list_or_die(void) { List *list = list_new(); + if (list == nullptr) { + die("out of memory allocating a list"); + } + return list; +} - assert(list->size == 0); - assert(list->head == NULL); - assert(list->tail == NULL); +/** 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; + } - list_destroy(list); + int out; + memcpy(&out, value, sizeof out); + return out; } -void test_list_size_new() -{ - List *list = list_new(); +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- - assert(list_size(list) == 0); +static void test_new_list_is_empty(void) { + List *list = new_list_or_die(); + + 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); } -void test_prepend_to_new_list() -{ - List *list = list_new(); - Node *node = malloc(sizeof(Node)); +/** + * Regression test: appending to an *empty* list used to link the node to + * 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. + */ +static void test_append_to_empty_list(void) { + List *list = new_list_or_die(); + + const int value = 42; + CHECK(list_append(list, &value, sizeof value)); + + CHECK(list_size(list) == 1); + 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. +} - list_prepend(list, node); +static void test_append_order(void) { + List *list = new_list_or_die(); - assert(list_size(list) == 1); + for (size_t i = 0; i < ITEM_COUNT; i++) { + const int value = (int)i; + CHECK(list_append(list, &value, sizeof value)); + } + + CHECK(list_size(list) == ITEM_COUNT); + + // 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(index == ITEM_COUNT); list_destroy(list); } -void test_prepend_10() -{ - List *list = list_new(); +static void test_prepend_order(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_prepend(list, &value, sizeof value)); + } - node->data = malloc(sizeof(Node)); - memcpy(node->data, &i, sizeof(int)); + CHECK(list_size(list) == ITEM_COUNT); - list_prepend(list, node); + // Prepending reverses it: the last one in is at the head. + for (size_t i = 0; i < ITEM_COUNT; i++) { + CHECK(int_at(list, i) == (int)(ITEM_COUNT - 1 - i)); } - assert(list_size(list) == 10); + list_destroy(list); +} - Node *node = list->head; - for (int i = 0; i < 10; i++) - { - assert(node->size == sizeof(int)); +/** + * 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. + */ +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; - int *val = (int *)node->data; - // printf("node value = %d, i= %d\n", *val, (9 - i)); - assert(*val == (9 - i)); + CHECK(list_append(list, source, sizeof *source)); - node = node->next; - } + *source = 999; + free(source); + + CHECK(int_at(list, 0) == 7); list_destroy(list); } -void test_append_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->next = NULL; - node->size = sizeof(int); + List *list = new_list_or_die(); - node->data = malloc(sizeof(Node)); - memcpy(node->data, &i, sizeof(int)); + const Record in = {.id = 3, .name = "abc"}; + CHECK(list_append(list, &in, sizeof in)); - list_append(list, node); - } + 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); +} - assert(list_size(list) == 10); +/** + * 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 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); - Node *node = list->head; - for (int i = 0; i < 10; i++) - { - assert(node->size == sizeof(int)); + list_destroy(list); +} - int *val = (int *)node->data; - // printf("node value = %d, i= %d\n", *val, i); - assert(*val == i); +static void test_mutate_in_place(void) { + List *list = new_list_or_die(); - node = node->next; - } + 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); } -void test_list_destroy_10() -{ - List *list = list_new(); +static void test_index_out_of_range(void) { + List *list = new_list_or_die(); - for (int i = 0; i < 10; i++) - { - Node *node = malloc(sizeof(Node)); - node->data = NULL; - node->next = NULL; - list_prepend(list, node); - } + const int value = 1; + CHECK(list_append(list, &value, sizeof value)); - assert(list_size(list) == 10); + 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); } -void test_list_prepend_value() -{ - List *list = list_new(); +static void test_pop_all(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)); + for (size_t i = 0; i < ITEM_COUNT; i++) { + const int value = (int)i; + CHECK(list_append(list, &value, sizeof value)); } - assert(list_size(list) == 10); + for (size_t i = 0; i < ITEM_COUNT; i++) { + int out = -1; + CHECK(list_pop(list, &out, sizeof out)); + CHECK(out == (int)i); + CHECK(list_size(list) == ITEM_COUNT - 1 - i); + } - // 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); + CHECK(list_is_empty(list)); - assert(sizeof(int) == temp_node->size); - temp_node = temp_node->next; - } + int out = -1; + CHECK(!list_pop(list, &out, sizeof out)); list_destroy(list); } -void test_list_append_value() -{ - List *list = list_new(); +/** + * 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_pop_with_wrong_size_keeps_element(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 value = 5; + CHECK(list_append(list, &value, sizeof value)); + + 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)); - assert(list_size(list) == 10); + 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 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_drop(list)); + CHECK(list_size(list) == ITEM_COUNT - 1); + CHECK(int_at(list, 0) == 1); - assert(sizeof(int) == temp_node->size); - temp_node = temp_node->next; + while (list_drop(list)) { + // Drain it. } + CHECK(list_is_empty(list)); + CHECK(!list_drop(list)); + list_destroy(list); } -void test_list_pop_all() -{ - List *list = list_new(); +/** + * 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)); + + const int second = 2; + CHECK(list_append(list, &second, sizeof second)); + CHECK(list_size(list) == 1); + 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); +} - 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); +static void test_clear_leaves_list_usable(void) { + List *list = new_list_or_die(); - list_prepend(list, node); + for (size_t i = 0; i < ITEM_COUNT; i++) { + const int value = (int)i; + CHECK(list_append(list, &value, sizeof value)); } - assert(list_size(list) == 10); + list_clear(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); + CHECK(list_is_empty(list)); + CHECK(list_first(list) == nullptr); - list_node_destroy(pop_node); - } + 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); } -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(); +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_bad_arguments_are_rejected(void) { + const int value = 1; + List *list = new_list_or_die(); + + // 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_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 documented no-ops, so this only has to not crash. + list_destroy(nullptr); + list_clear(nullptr); + + list_destroy(list); } -int main(void) -{ - tests_run_all(); -} \ No newline at end of file +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(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); +} + +// --------------------------------------------------------------------------- +// 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_append_order), + TEST_CASE(test_prepend_order), + TEST_CASE(test_value_is_copied), + TEST_CASE(test_struct_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_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), + }; + + 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 + ); + + return checks_failed == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +}