Native Builtins
argsh ships with optional Bash loadable builtins compiled from Rust. When the shared library is available, the core parsing commands run as native code inside the Bash process — zero fork overhead, zero subshell cost.
Overview
Bash supports loadable builtins via enable -f <path.so> <name>. argsh compiles its core functions (:args, :usage, type converters, introspection helpers) into a single .so that can be loaded at runtime. This replaces the pure-Bash implementations with native code while maintaining identical behavior.
The key benefits are:
- Performance: No subshell forks for type checking, field parsing, or help generation
- Transparent fallback: If the
.sois not found, everything works as before with the pure-Bash implementation - Identical behavior: The builtin output is byte-for-byte identical to the Bash implementation (validated by the test suite)
Usage (Shebang)
The simplest way to use native builtins is through the shebang line:
If the .so is not found locally, argsh automatically downloads it from the latest GitHub release.
To specify an install path:
If the file doesn't exist at that path, argsh downloads it there. On subsequent runs the cached .so is loaded directly.
| Flag | Description |
|---|---|
--builtin [path] | Require native builtins. Auto-downloads if not found. If path given, downloads to that path. |
--no-builtin | Skip builtin loading entirely. |
-i, --import <lib> | Import additional libraries (repeatable). |
--version | Print argsh version and exit. |
Available Builtins
| Builtin | Purpose |
|---|---|
:args | CLI argument parser with type checking |
:usage | Subcommand router with help generation |
is::array | Test if a variable is declared as an array |
is::uninitialized | Test if a variable is uninitialized |
is::set | Test if a variable is set (has a value) |
is::tty | Test if stdout is a terminal |
args::field_name | Extract variable name from a field definition |
to::int | Validate and pass through integer values |
to::float | Validate and pass through float values |
to::boolean | Convert values to boolean (0 or 1) |
to::file | Validate that a file path exists |
to::string | Identity conversion (pass through) |
import | Import modules with selective imports and aliasing |
import::clear | Clear the import cache |
Building
The builtin crate requires a Rust toolchain. From the repository root:
The shared library is output to builtin/target/release/libargsh.so. Copy it as argsh.so to a directory in your search path.
The .so is compiled for the current platform. Official releases provide binaries for linux/amd64 and linux/arm64. See Compatibility for glibc and Bash version requirements.
Loading
Automatic (recommended)
args.sh auto-detects and loads argsh.so at source time. It searches in order:
ARGSH_BUILTIN_PATH— explicit full path to the.soPATH_LIB/argsh.so— project library directoryPATH_BIN/argsh.so— project binary directoryLD_LIBRARY_PATH— standard system library path (colon-separated)BASH_LOADABLES_PATH— standard bash loadable builtins path (colon-separated)
# Option 1: Set explicit path
export ARGSH_BUILTIN_PATH="/path/to/argsh.so"
source libraries/args.sh
# Option 2: Copy to PATH_BIN (used by .envrc)
cp builtin/target/release/libargsh.so .bin/argsh.so
source libraries/args.sh # finds it via PATH_BIN
# Option 3: Install system-wide
cp builtin/target/release/libargsh.so /usr/local/lib/argsh.so
export BASH_LOADABLES_PATH="/usr/local/lib"
source libraries/args.sh
When builtins are loaded, args.sh sets ARGSH_BUILTIN=1 and skips the pure-Bash function definitions. The is and to libraries are still sourced, but their function definitions are removed via unset -f, allowing the faster builtin implementations from the .so to take effect.
Manual
You can load the builtins manually with enable -f:
To verify builtins are loaded:
How It Works
Bash loadable builtins are shared libraries that export a specific struct per builtin name. When enable -f is called, bash loads the .so via dlopen and looks up the <name>_struct symbol for each builtin name.
The argsh .so is compiled from Rust using the bash_builtins crate for FFI bindings. Each builtin:
- Receives the argument word list from bash
- Converts it to Rust
Vec<String> - Performs parsing/validation in native code
- Sets shell variables directly via bash FFI (
find_variable,set,array_set) - Returns an exit code
All builtins are wrapped in std::panic::catch_unwind to prevent Rust panics from crashing the bash process.
Priority and Function Overrides
Bash resolves commands in this order: aliases > functions > builtins. When args.sh auto-loads the .so, it wraps the pure-Bash function definitions in if ! (( ARGSH_BUILTIN )); then ... fi, so they are never defined when builtins are active. This way the builtins take effect without needing to unset -f anything.
If you load builtins manually after sourcing args.sh, the bash functions will shadow them. Use unset -f :args :usage to let the builtins take precedence.
Testing
The builtin implementation shares the same test suite as the pure-Bash implementation. Set ARGSH_BUILTIN_TEST=1 to run with native builtins:
Both modes produce byte-for-byte identical snapshot output, ensuring the builtin implementation matches the Bash implementation exactly.
Benchmark
Measured with bash bench/usage-depth.sh — 50 iterations per cell.
Subcommand dispatch (cmd x x ... x -h)
| Depth | Pure Bash | Builtin | Speedup |
|---|---|---|---|
| 10 | 1188 ms | 21 ms | 57x |
| 25 | 2686 ms | 53 ms | 51x |
| 50 | 5434 ms | 155 ms | 35x |
Argument parsing (cmd --flag1 v1 ... --flagN vN)
| Flags | Pure Bash | Builtin | Speedup |
|---|---|---|---|
| 10 | 5405 ms | 4 ms | 1351x |
| 25 | 13986 ms | 9 ms | 1554x |
| 50 | 29603 ms | 20 ms | 1480x |
Real-world (:usage + :args at every level, depth 10)
| Scenario | Pure Bash | Builtin | Speedup |
|---|---|---|---|
| 10 levels | 567 ms | 43 ms | 13x |
Each level parses 2 flags via :usage, then dispatches a subcommand. This reflects a typical CLI with nested commands where each level accepts its own options.
Self-Contained Variant (argsh-so)
argsh-so is a single self-contained executable that bundles the native .so directly inside the script. The raw .so binary is appended after the script portion — on startup, tail extracts it to a temp file, enable -f loads it, and the temp file is removed. No external .so file, no download step.
Each GitHub release provides one per architecture:
Install it as your shebang interpreter:
Because the file contains a binary payload after the script, it cannot be sourced — only executed via shebang or bash argsh-so. For sourcing, use the regular argsh script with its external .so or pure-Bash fallback.
argsh-so is platform-specific. The regular argsh script with pure-Bash fallback remains the portable, architecture-independent default.
Compatibility
Bash Version
The .so targets the Bash 5.x ABI. The bash_builtins Rust crate depends on internal struct layouts (SHELL_VAR, WORD_LIST, BUILTIN) that differ between Bash major versions. Loading the .so in Bash 4.x will fail or crash.
The pure-Bash fallback works on Bash 4.3+, so argsh itself supports older systems — only the native performance path requires Bash 5.x.
glibc
The .so dynamically links against glibc. The rule: build glibc ≤ runtime glibc. Official release builds target glibc 2.36 (Debian 12 / bookworm).
| Distro | glibc | Bash | Builtins |
|---|---|---|---|
| RHEL 9 | 2.34 | 5.1 | No (glibc too old) |
| Ubuntu 22.04 | 2.35 | 5.1 | No (glibc too old) |
| Debian 12 | 2.36 | 5.2 | Yes |
| Ubuntu 24.04 | 2.39 | 5.2 | Yes |
| Fedora 40+ | 2.39+ | 5.2+ | Yes |
Building against old glibc has no security implications — the runtime system's glibc (with its patches) handles execution. The .so cannot be statically linked because dlopen() requires dynamic glibc linking.
For more details and diagnostic steps, see the Troubleshooting page.
Custom Types
Custom to:: types defined as bash functions continue to work when builtins are loaded. The builtin :args command calls custom type functions via parse_and_execute internally. Only the built-in types (int, float, boolean, file, string) run as native code.