Build, test, and install workflow for working on sparcli itself. For the public API see api.md (the hub linking the C and C++ references); for the command-line tool see cli.md; for architecture and house conventions see ../CLAUDE.md.
Requirements: a C11 compiler (cc, clang, or gcc), make, and a POSIX system. The interactive PTY test suite uses forkpty.
make # libsparcli.a + shared lib (.dylib/.so) + sparcli.pc + the sparcli CLI
make shared # only the shared library
make pkgconfig # only sparcli.pc
make cli # only the sparcli command-line tool (see docs/cli.md)
make clean # remove all build trees, libs, test binaries
make rust # build the Rust binding (see "make rust" below)
make python # build the Python binding (see "make python" below)
make rebuild-all # C lib + install + Rust + Python in one commandCompiler flags are -std=c11 -Wall -Wextra. Treat warnings as errors with the EXTRA_CFLAGS hook (recommended before committing, and how CI-style checks are run):
make EXTRA_CFLAGS=-WerrorEXTRA_CFLAGS takes any extra flags, so it also serves debug builds: make EXTRA_CFLAGS='-g -O0'.
Good to know:
- Header-dependency tracking is automatic (
-MMD -MP): editing a header rebuilds every dependent object on the nextmake– you do not needmake cleanafter changing a header. - The sanitizer build uses a separate build tree and a separate
.a, so it never contaminates the normal artefacts. - Tests link against the static
libsparcli.a, so running them never depends on the install path or the dynamic-loader search order. - Linux and macOS are both supported; the shared-library naming (
.so/.dylib+ soname) and the-lutillink forforkpty(PTY suite) are selected automatically by the Makefile.
The suites split along the output/input boundary. Everything except make test-input (which needs a real terminal) runs headless.
The targets come in two tiers, and they are not content-equivalent – test ⊂ qa:
test*is the fast functional tier: build the test binaries and run them (optionally-Werror). "Do the checks pass?" Quick; what you run constantly while developing.qa*is the full pre-commit battery: it runs the matchingtesttier with-Werrorand then adds the heavy gates – sanitizers (ASan/UBSan), ThreadSanitizer, static analysis (cppcheck/clang-tidy), fuzzing, and the language bindings. "Is this commit-ready?" Much slower.
Concretely, make qa literally begins with make test EXTRA_CFLAGS=-Werror and layers the heavy gates on top (see the list below), so running make test covers a strict subset of what make qa does. The same relationship holds per opt-in area: test-serde ⊂ qa-serde and test-view ⊂ qa-view.
Naming convention. The main gate is make qa; the opt-in data layers get sibling area gates make qa-serde and make qa-view, with their individual steps under the same prefix (qa-serde-sanitize, qa-serde-fuzz, qa-serde-cpp, qa-view-sanitize, qa-view-cpp). Plain functional runners keep the test-* name (test-serde, test-view).
The canonical "is everything OK?" command. Runs all headless gates in order – test-output-check, test-input ARGS=--logic, test-input-style-check, test-input-pty, test-app, test-args, test-cpp, test-cli-check, test-cli-pty – and fails if any does. Command-line overrides propagate, so make test EXTRA_CFLAGS=-Werror builds every gate with warnings as errors. The interactive widget suite (make test-input) needs a real TTY and is not included. The individual targets it chains are documented below.
make test # run every headless gate
make test EXTRA_CFLAGS=-WerrorThe complete pre-commit validation. Runs, in order and stopping at the first failure:
make test EXTRA_CFLAGS=-Werror– build + every headless test gate, warnings as errorsmake sanitize– output suite under ASan/UBSanmake tsan– logic suite under ThreadSanitizermake lint– cppcheck + clang-tidy, warnings as errorsmake fuzz– random-input fuzzing of all external parsers (markup, key decoder, sanitizer, CSV, argv)make rust-test– Rust binding (rebuilds the C sources viacc)make rust-lint– cargo clippy over the Rust workspace, warnings as errorsmake python-test– Python binding (rebuilds the cffi extension)make python-test-debug– Python suite with poisoned freed memory (FFI lifetime gate)
make qa # the one command to run after any changeRun this before every commit; the individual targets are documented below for running a single gate during development.
The output renderer suite in tests/output/, printed to stdout for visual inspection (the golden-file gate below is the automated check). Covers: text attributes, colors, panels, alerts, tables, rules, trees, lists, progress bar, spinner, key-value pairs, badges, utilities, padding, alignment, markup, and columns (basic + advanced).
make test-output # all output tests
make test-output ARGS=--no-animated # skip the spinner/progress animations
make test-output ARGS=--focus # run only the focused subset
make test-output ARGS="--focus --no-animated" # combine flagsThe two animated cases (progress bar, spinner) are skipped by --no-animated.
Runs the output suite with --no-animated and diffs it against tests/output/expected.txt. Fails on any rendering drift. This is the automated correctness gate for the output side.
make test-output-check # diff against the committed snapshot
make test-output-golden # regenerate the snapshot (after an intended change)Drives every input widget for real in tests/input/logic/. Needs a real TTY – run it in a terminal, not a pipe.
make test-input # interactive: confirm, text, password, number,
# textarea, select, fuzzy, datepicker, theme
make test-input ARGS=--logic # non-interactive logic checks only (CI-safe)ARGS=--logic skips the interactive widgets and runs only the pure-logic checks – key decoder, the shortcut chord matcher, the select label-edit API, the line editor, character filters, and the thread-safety test – and exits non-zero if any assertion fails.
Renders every input widget in many styles via the internal frame builders, with no TTY and no keystrokes. Safe anywhere.
make test-input-style # print all style snapshots
make test-input-style-check # diff against tests/input/style/expected.txt
make test-input-style-golden # regenerate the snapshotForks each interactive widget onto a pseudo-terminal, feeds it a canned keystroke script, and asserts the returned value – no human needed. Built with AddressSanitizer + UndefinedBehaviorSanitizer, so it also gives sanitizer coverage of the interactive code paths. Covers confirm, text, number, select, textarea, and datepicker, plus custom shortcuts (return/callback/remove), Esc-cancel, and the external-editor flow (driven by a stub editor).
make test-input-ptyThe framework helpers in tests/app/: XDG path resolution (environment overrides, $HOME fallbacks, directory creation, path-traversal rejection – inside a sandboxed $HOME, never touching real user directories), the pager (no-op off terminal, piping through cat, exit-code propagation, SIGPIPE robustness, fallback for missing commands), pretty errors (builder copies, NULL safety, stderr routing), and logging (sinks, levels, formatting, markup, sanitizing). Pure logic with real child processes; no TTY required.
make test-appThe args module in tests/args/: the parse loop (long/short/combined options, --opt=value, -- terminator, subcommands, positionals, variadic collection), typed value conversion, error reporting with did-you-mean suggestions, argv ANSI-sanitization, and the help/completion renderers (checked by capturing their output and asserting on the plain-text content). No TTY required.
make test-argsFour checks for the header-only C++20 wrapper (include/sparcli.hpp); needs a C++ compiler ($(CXX), default c++):
- Compile
tests/cpp/cpp_demo.cpp(so the example never bit-rots). - Golden diff the demo's output against
tests/cpp/expected.txt. - Assertion suite
tests/cpp/test_cpp.cpp– verifies the wrapper's ownership/lifetime guarantees (table built from temporaries,Tablesurvives a move, List/Tree rich-text arenas) and that it renders like the C API. Built under AddressSanitizer + UBSan so arena/RAII/move memory bugs are caught. - PTY regression
tests/cpp/test_cpp_pty.cpp– drives the interactive string prompts (text_input/password_input/textarea) over a pseudo-terminal and asserts the returned value, so the out-param sequencing those wrappers once got wrong stays fixed. Also under ASan/UBSan.
make test-cpp # all three checks
make test-cpp EXTRA_CFLAGS=-Werror
make test-cpp-golden # regenerate tests/cpp/expected.txtRuns tests/cli/run_output.sh, which invokes the sparcli binary for every non-interactive subcommand (print, panel, rule, table, list, tree, kv, alert, badge, columns, plus help texts and error cases) with fixed arguments and the fixtures in tests/cli/fixtures/, and diffs the bytes against tests/cli/expected.txt. Deterministic because stdout is a pipe (terminal width falls back to 80) and layout-sensitive cases pass an explicit --width. The animated commands (spin, progress) are excluded here and covered by the PTY gate below.
make test-cli-check # diff against the committed snapshot
make test-cli-golden # regenerate the snapshot (after an intended change)Drives the interactive CLI subcommands (confirm, input, password, number, textarea, select, fuzzy, date) on a pseudo-terminal: tests/cli/test_cli_pty.c forks a sanitizer-instrumented CLI binary (sparcli-sanitize), redirects its stdout onto a pipe (exactly like shell command substitution), feeds canned keystrokes through the PTY, and asserts both the value on stdout and the exit code. Also covers the no-TTY error paths (exit 2) and spin's exit-code propagation. Both the harness and the CLI child run under AddressSanitizer + UBSan.
make test-cli-ptyThe safe Rust crate lives in bindings/rust/ (a cargo workspace: sparcli-sys + sparcli). sparcli-sys/build.rs compiles the C sources with the cc crate, so these targets need only a Rust toolchain (cargo) – no prior make or install. Kept out of make test since cargo may be absent.
make rust # cargo build (compiles the C via cc, links the FFI)
make rust-test # cargo test: non-interactive integration tests + doctests
make rust-lint # cargo clippy --all-targets -- -D warnings (part of make qa)
cargo build --manifest-path bindings/rust/Cargo.toml --features regen # bindgen path (libclang)
# Examples (the workspace has no bin, so plain `cargo run` fails – pick one).
# They are grouped by area as <group>_<file>; see docs/examples.md for the
# full list. From inside bindings/rust/ :
cargo run -p sparcli --example output_table_basic
cargo run -p sparcli --example input_fuzzy # needs a terminal
# Or from the repo root, for any language:
make run-example EX=rust/output/table_basicFFI bindings are committed (bindings/rust/sparcli-sys/src/bindings.rs); the regen feature regenerates them from the headers with bindgen. The reference is docs/api-rust.md.
make rust-test sets SPARCLI_NO_TTY=1 so the smoke tests that assert the library's no-TTY behavior (prompt errors, pager no-op, live buffering) stay non-interactive even when the suite is started from a real terminal – cargo test only redirects stdin/stdout, the controlling terminal stays reachable via /dev/tty and isatty(), and without the override a prompt would grab the keyboard and the pager would spawn a blocking less. A direct cargo test from an interactive terminal skips those tests instead.
The Pythonic sparcli package lives in bindings/python/ (a cffi API-mode wrapper). build_sparcli.py compiles the vendored C sources – reached through the in-tree csrc/cinclude symlinks (→ ../../src, ../../include) – into src/sparcli/_sparcli_cffi.*, so make python builds in place and the tests run with PYTHONPATH=src (the examples under examples/python/ with PYTHONPATH=bindings/python/src), no install required. Needs Python with cffi; kept out of make test since neither may be present.
make python # build the extension in place
make python-test # build + run the non-interactive pytest suite
make python-test-debug # same suite with poisoned freed memory (see below)
make python-test PY=/path/to/python # point make at an interpreter that has cffi
# Both test targets (and tests/conftest.py, for direct `pytest` runs) set
# SPARCLI_NO_TTY=1 so the no-TTY prompt tests never grab a real terminal.
# Examples (after `make python`, from the repo root). Grouped by area under
# examples/python/; see docs/examples.md for the full list:
PYTHONPATH=bindings/python/src python examples/python/output/table_basic.py
PYTHONPATH=bindings/python/src python examples/python/input/fuzzy.py # needs a terminal
# Or, for any language: make run-example EX=python/output/table_basicTo install into an environment instead – an editable (-e) install with build isolation off, because the C sources are reached via the in-repo symlinks and the build must run in place:
pip install cffi setuptools wheel
pip install --no-build-isolation -e bindings/pythonThe cdef in build_sparcli.py uses cffi's partial-struct (...;) syntax, so the compiler fills in exact struct layout from the headers – a real mismatch fails the build. The reference is docs/api-python.md.
Runs the same pytest suite, but with every freed allocation overwritten by a marker byte (PYTHONMALLOC=malloc + macOS MallocScribble/glibc MALLOC_PERTURB_). A use-after-free in the C layer – e.g. a borrowed string whose cffi buffer was garbage-collected – then shows up as garbled output and a failing assertion instead of silently "working" because the old bytes happen to still be there. Run this after any change to the bindings or to string-lifetime handling.
This is the gate that would have caught the Select/Fuzzy opts use-after-free found via numcli: tests with string literals can never dangle, and ASan only reports memory that actually gets freed – poisoning makes the dangling read deterministic.
Rebuilds and runs the output suite with AddressSanitizer + UBSan in a separate build tree.
make sanitize ARGS=--no-animatedThe style and PTY runners take no
ARGS. The thread-safety test runs as part ofmake test-input ARGS=--logic.
Rebuilds the library with -fsanitize=thread (own build tree, since TSan and ASan cannot be combined) and runs the non-interactive logic suite, including the thread-safety test. This verifies the documented invariant that concurrent rendering/capturing on independent streams is race-free, instead of just asserting it.
make tsanRuns cppcheck and clang-tidy (configured by the repo-root .clang-tidy; conservative bug-finding checks, noisy style checks disabled with documented reasons) over src/. Warnings are treated as errors (--warnings-as-errors='*' / cppcheck --error-exitcode=1), consistent with -Werror and clippy -D warnings – if an LLVM upgrade introduces a misfiring check, disable it in .clang-tidy rather than relaxing the gate. Each tool is optional – the target prints an install hint (brew install cppcheck / brew install llvm) when one is missing.
make lintFeeds the external parsers – the inline-markup parser (sc_markup_parse), the key decoder (sc_key_decode), the ANSI sanitizer (sc_sanitize_copy / sc_strip_ansi), and the argument parser/line splitter (sc_args_parse / sc_args_split) plus the calculator (sc_calc_eval) – with pseudo-random byte sequences under ASan/UBSan. The sanitizer harness additionally asserts its output contract (no ESC/control bytes when disallowed, output never longer than the input). Deterministic by default; tune with FUZZ_ITERS / FUZZ_SEED. The harnesses in tests/fuzz/ expose a libFuzzer-compatible LLVMFuzzerTestOneInput, so they can also run under a real libFuzzer toolchain (-DSPARCLI_LIBFUZZER -fsanitize=fuzzer,address; Apple clang does not ship libFuzzer). The serde-layer parsers (JSON, CSV, TOML, YAML, Markdown) are fuzzed separately by make qa-serde-fuzz (part of make qa-serde, not make fuzz/qa).
make fuzz # 200k inputs per parser, seed 1
make fuzz FUZZ_ITERS=1000000 FUZZ_SEED=42The serde layer (src/serde/ – the JSON/CSV/TOML/YAML/Markdown read+write parsers over ScValue) has its own QA gate, deliberately kept out of make test/make qa so the data-parser work is validated independently of the terminal library. Run make qa-serde after any src/serde/ change; it chains:
make test-serde– the data-model + format round-trip logic suite (tests/serde/, headless, built-Werror),make qa-serde-sanitize– the same suite under AddressSanitizer + UBSan,make qa-serde-fuzz– random-input fuzzing of all five parsers (libFuzzer-compatible,FUZZ_ITERS/FUZZ_SEEDas above),make qa-serde-cpp– the C++ wrapper assertion suite (tests/cpp/test_serde_cpp.cpp) under the sanitizers.
make lint (part of make qa) does cover src/serde/, so still run it on serde changes. The serde suite uses logic + round-trip assertions (no golden files), and the examples/{c,cpp}/data/ examples double as a secondary check (make examples). Full API: api-serde.md.
The view layer (src/view/ – renders the serde ScValue/Markdown models to the terminal through the widget stack) is opt-in like serde and has its own QA gate, also outside make test/make qa. Run make qa-view after any src/view/ change; it chains:
make test-view– the render logic suite (tests/view/, headless, built-Werror),make qa-view-sanitize– the same suite under AddressSanitizer + UBSan,make qa-view-cpp– the C++ wrapper assertion suite (tests/cpp/test_view_cpp.cpp) under the sanitizers.
Since the view layer depends on both serde and output, run make qa-serde as well when a change touches both.
test-output-check, test-input-style-check, test-cli-check and the C++ test-cpp gate diff the rendered bytes against committed snapshots (deterministic because, off a TTY, the terminal width falls back to 80). When you change rendering on purpose:
- regenerate with
make test-output-golden,make test-input-style-golden,make test-cli-goldenand/ormake test-cpp-golden, - review the diff to confirm the change is what you intended,
- commit the updated
expected.txt.
Copy-paste block to validate a change.
# 1. The full QA run: every gate in order (= the pre-commit validation).
# Equivalent to running, in order: make test EXTRA_CFLAGS=-Werror,
# make sanitize, make tsan, make lint, make fuzz, make rust-test,
# make rust-lint, make python-test, make python-test-debug.
make qa
# 1b. If you touched the serde layer (src/serde/), run its separate gate. It is
# deliberately NOT part of `make qa`: test-serde (-Werror) + qa-serde-sanitize
# (ASan/UBSan) + qa-serde-fuzz (all five parsers) + qa-serde-cpp (C++ wrapper).
make qa-serde
# 1c. If you touched the view layer (src/view/), run its separate gate (also NOT
# part of `make qa`): test-view (-Werror) + qa-view-sanitize + qa-view-cpp.
make qa-view
# 2. If you changed rendering on purpose, regenerate + review + commit the golden:
make test-output-golden
make test-input-style-golden
make test-cli-golden # only if you changed the CLI's output
make test-cpp-golden # only if you changed the C++ wrapper
# 3. Examples still build (and try the input widgets by hand). They are
# grouped by language and area under examples/; see docs/examples.md.
make examples # compile every C + C++ example
make run-example EX=readme_screenshots_output # output widget gallery (static)
make run-example EX=readme_screenshots_input # input widget gallery (static)
make run-example EX=c/input/confirm_select # one input widget (interactive)
make run-example EX=c/input/shortcuts_theme # custom shortcuts: F2 archive, Ctrl-X delete
./examples/cli/output_demo.zsh # CLI output commands demo (zsh)
./examples/cli/input_demo.zsh # CLI input commands demo (zsh, interactive)
# 4. Interactive widget suite (needs a real terminal):
make test-inputsparcli is fully Windows-native. The Windows gate is make qa-win, run from
an MSYS2 UCRT64 shell (not PowerShell or the plain "MSYS2 MSYS" shell – only
UCRT64 has the right gcc on PATH). It builds the library, the .dll and the
CLI with -Werror and runs the ConPTY interactive-widget gate
(make test-conpty, the Windows counterpart of make test-input-pty). make qa
(macOS/Linux) stays the full gate – the TSan/UBSan and real-PTY steps are
Unix-only.
# In the MSYS2 UCRT64 shell (Start menu "MSYS2 UCRT64", or C:\msys64\ucrt64.exe):
cd /c/path/to/sparcli
make qa-win # build (lib + dll + cli, -Werror) + ConPTY widget tests
make qa-win-compile # quick compile-only smoke check of the portable coreToolchain setup, the per-subsystem POSIX↔Win32 mapping, and the binding builds
(MSVC wheels, cargo test via the x64-emulated toolchain) are documented in
windows.md.
The C++/Rust/Python bindings and any external consumer each carry their own copy of the compiled library (the Rust and Python wrappers compile the C sources themselves; pkg-config consumers link the installed .a/.so). None of them pick up a src/ or include/ change automatically – after editing the C core, rebuild each one you use. All commands are relative to the project root.
All at once: make rebuild-all runs steps 1–3 below in order (C library + install + Rust + Python); the C++ wrapper is header-only and needs no rebuild. It needs cargo and a cffi-capable Python – point at one with make rebuild-all PY=/path/to/python. install defaults to /usr/local (needs sudo); pass a prefix for a user-local install, e.g. make rebuild-all PREFIX=$HOME/.local. Run the individual steps instead when you only touched one binding.
# 1. Core C library + (re)install – needed by pkg-config consumers, the C/C++
# headers, and any external project that links the installed lib.
make # rebuild libsparcli.a + .so + .pc
make install # reinstall (user-local; or `sudo make install`)
# 2. Rust binding – build.rs recompiles the C via cc (no install needed).
cargo build --manifest-path bindings/rust/Cargo.toml
cargo test --manifest-path bindings/rust/Cargo.toml
# If a struct / signature / enum changed, regenerate the committed bindgen
# output (needs libclang) and commit it:
cargo build --manifest-path bindings/rust/Cargo.toml --features regen
# -> commit bindings/rust/sparcli-sys/src/bindings.rs
# 3. Python binding – rebuild the cffi extension (it is NOT rebuilt on import).
make python # build in place into src/sparcli/
make python-test # build + headless pytest
# An editable install does not auto-rebuild either – re-run it after a change:
uv pip install --no-build-isolation -e bindings/python
# If a struct/signature the wrapper uses changed or was added, first update
# the cdef in bindings/python/build_sparcli.py (and the dataclass `_fill`).
# 4. C++ wrapper – header-only; if the C API changed, update include/sparcli.hpp,
# then recompile dependents and rerun the gate:
make test-cppEasy to forget:
- Version bump on ABI changes: edit
SPARCLI_VERSION_*ininclude/core/sparcli_export.h; the shared-lib version and.pctrack it, so re-runmake installand rebuild every consumer. - Golden files if you changed rendering on purpose:
make test-output-golden,make test-input-style-golden,make test-cpp-golden(review + commit). - Docs in lockstep:
CLAUDE.md, the per-language referencesdocs/api-c.md/api-cpp.md/api-rust.md/api-python.md, andREADME.md. - External consumers that link the installed library must be rebuilt after
make install. - The cdef is hand-maintained (Python) and the bindgen output is committed (Rust): a new/changed public function or struct field is invisible to those bindings until you update
build_sparcli.py/ regenbindings.rs.
make install # default PREFIX=/usr/local (may need sudo)
make install PREFIX=$HOME/.local # user-local install
make install DESTDIR=/tmp/stage # staged install (packaging)
make uninstallmake install lays down:
libsparcli.aand the shared library (with thesoname/link symlinks) in$(LIBDIR),- the public headers under
$(INCLUDEDIR)/{core,output,input}/(plus thesparcli.humbrella), sparcli.pcin$(PKGCONFIGDIR),- the
sparcliCLI binary in$(BINDIR)(default$(PREFIX)/bin), - the zsh completion
_sparcliin$(ZSHFUNCDIR)(default$(PREFIX)/share/zsh/site-functions).
Consumers then build against it with pkg-config:
cc myapp.c $(pkg-config --cflags --libs sparcli) -o myappThe default PREFIX=/usr/local is not writable without root on most macOS setups, so make install fails there with Operation not permitted. Rather than sudo (which leaves root-owned files in /usr/local – and, if you ever build as root, root-owned objects in the build tree that later block a normal make), install into your home prefix instead:
make clean # rebuild so the dylib's install_name
make install PREFIX="$HOME/.local" # points at ~/.local, not /usr/localWhy
make cleanfirst: the shared library'sinstall_nameis baked in at link time fromPREFIX. Reusing a dylib that was linked for/usr/localwould make the loader look there at run time and fail. A clean rebuild withPREFIX="$HOME/.local"links it (and writessparcli.pc) for the right path.
pkg-config does not search ~/.local by default, so point it there once – add to ~/.zshrc (or ~/.bashrc):
# Let pkg-config find libraries installed under ~/.local (e.g. sparcli)
echo 'export PKG_CONFIG_PATH="$HOME/.local/lib/pkgconfig:$PKG_CONFIG_PATH"' >> ~/.zshrc
source ~/.zshrcVerify it resolves to your home prefix (not a stale /usr/local copy):
pkg-config --cflags --libs sparcli # → -I$HOME/.local/include -L$HOME/.local/lib -lsparcliRemove later with make uninstall PREFIX="$HOME/.local".
If you want to keep the default PREFIX=/usr/local (so consumers need no PKG_CONFIG_PATH setup and pkg-config / the linker find sparcli out of the box), the alternative is a one-time ownership change of the three install directories:
sudo install -d -o "$(whoami)" -g admin \
/usr/local/lib /usr/local/include /usr/local/lib/pkgconfig(install -d creates the directories if missing and re-owns them if they already exist – one command covers both cases.)
After that, make install / make uninstall work without sudo – for sparcli and any other project installing there. This is long-standing practice on macOS (Intel-era Homebrew owned /usr/local the same way); SIP protects /usr/local itself, not its contents. No make clean is needed since the prefix does not change.
Trade-off: those directories become writable by your user account, so any process running as you can place libraries there. Prefer the ~/.local install above if that matters in your environment.
The version string lives in three places that must stay in sync – the most common release mistake is bumping only some of them:
| Location | What it drives |
|---|---|
Makefile – VERSION_MAJOR / _MINOR / _PATCH |
shared-library version + soname, sparcli.pc |
include/core/sparcli_export.h – SPARCLI_VERSION_MAJOR / _MINOR / _PATCH |
compile-time constants; sc_version() / sc_version_string() |
README.md – the ![Version: …] badge |
the version shown on the project page |
Checklist:
make test EXTRA_CFLAGS=-Werroris green; regenerate goldens if rendering changed intentionally.- Bump the version in all three locations above.
make clean && make–sparcli.pcand the soname pick up the new value; confirm with./build.examples.nosync/...or a quicksc_version_string()check.- Commit, then tag and push:
git tag vX.Y.Z && git push --tags. - (optional) Write GitHub release notes / a CHANGELOG entry.
Bumping the major changes the soname, so dependents must relink.
make examples # build every examples/*.c
make run-example EX=readme_screenshots_output # static gallery of output widgets
make run-example EX=readme_screenshots_input # static gallery of input widgets
make run-example EX=shortcut_demo # custom shortcuts + rich prompt (interactive)
./examples/cli/output_demo.zsh # sparcli CLI: every output command (zsh)
./examples/cli/input_demo.zsh # sparcli CLI: every input command (zsh, interactive)Examples are auto-discovered ($(wildcard examples/*.c)): dropping a new examples/foo.c in makes make run-example EX=foo work – no Makefile edit. The two cli_*.zsh scripts are plain zsh scripts that run the ./sparcli binary built by make (point them at another binary with SPARCLI=/path/to/sparcli).
src/core/ color, text, print, output stream, render-wrap, version
src/output/ panel, rule, list, tree, columns, kv, alert, badge,
progressbar, spinner, markup, pad, util, pager, + table/
src/tty/ raw mode + signal restore, key decoder, in-place redraw
src/input/ prompt engine, line editor, shortcut, external editor, confirm,
text/password/number, textarea, select, fuzzy, datepicker, theme
src/app/ application-framework helpers: paths (XDG dirs), error (sc_die)
src/log/ logging: leveled terminal + plain-text file sinks
src/args/ argument parser (builder, parse, values, help, suggest, completion)
include/{core,output,input,app}/ public C headers (sparcli.h is the umbrella)
include/sparcli.hpp header-only C++20 wrapper (RAII over the C API)
cli/ the sparcli command-line tool (subcommand per widget)
completions/ zsh completion (_sparcli) for the CLI
bindings/rust/ cargo workspace: sparcli-sys (FFI) + sparcli (safe)
bindings/python/ cffi (API-mode) wrapper: src/sparcli/ + build_sparcli.py
tests/output/ output suite
tests/input/{logic,style,pty}/ interactive / snapshot / PTY suites
tests/app/ application-framework suite (paths, pager, errors, log)
tests/args/ argument-parser suite
tests/cpp/ C++ wrapper assertion suite + golden
tests/cli/ CLI golden-file suite + CLI PTY suite
The C++ wrapper (include/sparcli.hpp, namespace sparcli) is a thin, header-only layer: RAII handle classes, owned strings where the C API borrows (tables), std::optional/std::vector returns for input, and a .get() escape hatch to the C API. It is verified by make test-cpp and documented in api-cpp.md. Keep both in sync when you add or change public C functions.
The output/input boundary is physical: src/core and src/output are stream-oriented and write through the redirectable sc_output_stream(); src/tty and src/input drive a real terminal in raw mode.
To add a source file, append its path (e.g. src/output/foo.c) to SRC in the Makefile – the build tree mirrors src/ automatically. Public headers go under include/<area>/, and cross-includes use root-relative paths (#include "core/sparcli_core.h", resolved via -Iinclude). The table renderer is split into sub-modules that are #include-chained from table.c, so only src/output/table/table.c appears in SRC. The Rust and Python bindings compile the C sources themselves, so a new .c must also be added to the source list in bindings/rust/sparcli-sys/build.rs and bindings/python/build_sparcli.py – otherwise those builds fail to link the new symbols (an unresolved-symbol error at import/test time).
To add public API, declare it inside the header's SPARCLI_BEGIN_DECLS / SPARCLI_END_DECLS block (these provide the extern "C" wrapper for C++ consumers) and mark the prototype SPARCLI_EXPORT – symbols are hidden by default, so an unmarked function won't be part of the shared-library ABI.
Every pointer a public API receives needs an explicit answer to "who owns this, for how long?". Getting this wrong is invisible to normal tests (string literals never dangle) and surfaces as use-after-free only in FFI consumers – so it is a design-time checklist, not something tests catch for free:
- Does the function store a caller pointer past its own return? (handle-based
*_new(), process-wide setters, deferred rendering)- Default: copy it (and free the copy in the matching
_free()). This is the project invariant – see "Opts-string lifetime" in../CLAUDE.md. - Only borrow when copying is genuinely impossible/expensive (e.g.
ScTextcontent,shortcutscallbacks) – then document the required lifetime per field in the header.
- Default: copy it (and free the copy in the matching
- Write a lifetime test that hands over a temporary buffer and clobbers/frees it right after the call:
- Logic suite: clobber a stack buffer (pattern:
tests/input/logic/test_opts_copy.c). - PTY suite:
strdup+freebefore the run, so ASan sees a real dangling read (pattern: casesfuzzy-heap-prompt,theme-heap-strings). - Output suite: clobbered buffer + golden comparison (pattern: "Opts strings are copied" cases in
test_lists.c/test_progressbar.c). - Python: construct from a temporary,
gc.collect(), then use the object (pattern:test_*_survive_gcinbindings/python/tests/test_smoke.py) – and run it undermake python-test-debug.
- Logic suite: clobber a stack buffer (pattern:
- Sanitize at the trust boundary. Every new public entry point that accepts user text must route it through the ANSI sanitizer exactly once (
sc_sanitize_copy_modehonoring the widget'sScAnsiMode ansiopts field, orsc_sanitize_copy(str, sc_allow_ansi())when there are no opts) and use the internal raw paths (sc_text_append_raw,sc_print_raw) afterwards – never re-sanitize library-rendered content. See "ANSI sanitization / trust boundary" in../CLAUDE.md. - Update the bindings and docs (cdef/bindgen/hpp,
api-*.md,CLAUDE.md) – see the rebuild section above.
- Output test: add
tests/output/test_foo.c, append it toTEST_SRCin theMakefile, and register it inget_all_tests()intests/output/test_main.c. If it renders deterministic output, regenerate the golden file afterwards (make test-output-golden). - Input logic/widget test: add the source to
INPUT_TEST_SRC, declare the entry point intests/input/logic/test_input.h, and add it to the runner table intests/input/logic/test_input_main.c(mark itinteractiveor not). - Input style snapshot: add to
STYLE_TEST_SRCand thetests/input/style/runner, thenmake test-input-style-golden. - Application-framework test: add the source to
APP_TEST_SRC, declare the entry point intests/app/test_app.h, and add it to the runner table intests/app/test_app_main.c. - C++ wrapper check: add a
CHECK(...)case totests/cpp/test_cpp.cpp(no Makefile change –test-cppcompiles that file directly); if it changes the demo output, runmake test-cpp-golden. - CLI output case: add the invocation to
tests/cli/run_output.sh(use thesection/expect_failhelpers and, for layout-sensitive commands, an explicit--width), thenmake test-cli-golden. - CLI input case: add an entry to the
CASES[]array intests/cli/test_cli_pty.c(CLI argv, keystrokes, expected stdout + exit code;no_tty = truefor non-terminal behavior). No Makefile change needed.
See ../CLAUDE.md for the full module map and type reference.
The full reference lives in ../CLAUDE.md; the essentials:
- Doc comments sit above the member/field (
/** … */), with a blank line between members; enum values keep a trailing/**< … */. struct/enumtypedefs carry a tag (typedef struct Name { … } Name;).- Horizontal alignment fields/params are
halign, verticalvalign– never a barealign(that name is reserved for thesc_align_*operation). - Lines stay within 80 columns.
- Zero-initialized
ScColor/ScTextStylemeans "not set / no formatting" – rely on it instead of spelling out defaults. - String vs rich-text entry points come in
_str/_textpairs; terse inline constructors have FFI-friendly exported variants.
make qa– the full QA run passes: build with-Werror, all headless test gates, ASan/UBSan, TSan, lint, fuzzing, and both bindings (Rust + Python incl. the poisoned-memory gate). See "make qa" above for what it chains.- If you changed rendering on purpose, regenerate the affected golden file (
make test-output-golden/make test-input-style-golden/make test-cli-golden/make test-cpp-golden), review the diff and commit it. - If you added public API: walk the ownership checklist above (copy vs. documented borrow + lifetime test) and update the bindings (cdef / bindgen / hpp) + docs.
- If you changed the C API surface: regenerate the Rust bindgen output (
cargo build --manifest-path bindings/rust/Cargo.toml --features regen) and commitbindings.rs.