Skip to main content
Skip to main content

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 .so is 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)

Available Builtins

BuiltinPurpose
:argsCLI argument parser with type checking
:usageSubcommand router with help generation
is::arrayTest if a variable is declared as an array
is::uninitializedTest if a variable is uninitialized
is::setTest if a variable is set (has a value)
is::ttyTest if stdout is a terminal
args::field_nameExtract variable name from a field definition
to::intValidate and pass through integer values
to::floatValidate and pass through float values
to::booleanConvert values to boolean (0 or 1)
to::fileValidate that a file path exists
to::stringIdentity conversion (pass through)
importImport modules with selective imports and aliasing
import::clearClear the import cache

Building

The builtin crate requires a Rust toolchain. From the repository root:

cd builtin && cargo build --release

The shared library is output to builtin/target/release/libargsh.so. Copy it as argsh.so to a directory in your search path.

Note

The .so is compiled for the current platform. Cross-compilation requires the appropriate Rust target and compatible bash headers.

Loading

args.sh auto-detects and loads argsh.so at source time. It searches in order:

  1. ARGSH_BUILTIN_PATH — explicit full path to the .so
  2. PATH_LIB/argsh.so — project library directory
  3. PATH_BIN/argsh.so — project binary directory
  4. LD_LIBRARY_PATH — standard system library path (colon-separated)
  5. 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:

enable -f /path/to/argsh.so \
:usage :args \
is::array is::uninitialized is::set is::tty \
args::field_name \
to::int to::float to::boolean to::file to::string

To verify builtins are loaded:

enable -p | grep argsh
# or check individual builtins:
type :args # should show ":args is a shell builtin"

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:

  1. Receives the argument word list from bash
  2. Converts it to Rust Vec<String>
  3. Performs parsing/validation in native code
  4. Sets shell variables directly via bash FFI (find_variable, set, array_set)
  5. 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:

# Pure-Bash tests
ARGSH_SOURCE=argsh bats libraries/args.bats

# Builtin tests (requires built .so)
ARGSH_BUILTIN_TEST=1 ARGSH_SOURCE=argsh bats libraries/args.bats

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)

DepthPure BashBuiltinSpeedup
101188 ms21 ms57x
252686 ms53 ms51x
505434 ms155 ms35x

Argument parsing (cmd --flag1 v1 ... --flagN vN)

FlagsPure BashBuiltinSpeedup
105405 ms4 ms1351x
2513986 ms9 ms1554x
5029603 ms20 ms1480x

Real-world (:usage + :args at every level, depth 10)

ScenarioPure BashBuiltinSpeedup
10 levels567 ms43 ms13x

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.

Inline .so Variant (argsh-so)

CI produces a second artifact, argsh-so, that embeds the .so as a base64 blob directly inside the minified argsh script. On startup it decodes the blob to a temp file, loads it via enable -f, then removes the temp file. This gives you a single self-contained script with native performance — no external .so needed.

# Use the SO variant as your shebang
#!/usr/bin/env argsh-so
Note

The inline .so is platform-specific (linux/amd64). The regular argsh script with pure-Bash fallback remains the portable default.

Shebang Parameters

When using argsh as a shebang interpreter, you can pass flags before the script path:

#!/usr/bin/env argsh --builtin /path/to/argsh.so
#!/usr/bin/env argsh --no-builtin
#!/usr/bin/env argsh --import mylib
FlagDescription
--builtin [path]Require native builtins. If path given and not found, fail.
--no-builtinSkip builtin loading entirely.
-i, --import <lib>Import additional libraries (repeatable).
--versionPrint argsh version and exit.

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.

# This custom type works with both Bash and builtin mode
to::uint() {
local value="${1}"
[[ "${value}" =~ ^[0-9]+$ ]] || return 1
echo "${value}"
}
Was this section helpful?