A Bash interpreter written in pure Elixir.
Execute shell scripts from Elixir with compile-time validation, persistent sessions, and the ability to extend Bash with Elixir functions.
Quick Start
# Add to mix.exs
{:bash, "~> 0.3.0"}# Run a command
{:ok, result, _session} = Bash.run("echo hello")
Bash.stdout(result)
#=> "hello\n"
# Start a session and run many commands
result = Bash.with_session(fn session ->
session
|> Bash.run("echo hello")
|> Bash.run("echo uhoh >&2")
|> Bash.stdout()
end)
#=> "hello\n"
# Or use the sigil for compile-time parsing
import Bash.Sigil
iex> ~BASH"echo 'uh oh' >&2 && echo 'heyo'"O # 'O' modifier executes and returns both stdout and stderr
"uh oh\nheyo\n"
iex> ~BASH"ls -la | head -5"S # 'S' modifier executes and returns only stdout
"total 12536\ndrwxr-xr-x@ 4 dbern staff 128 Jan 20 02:27 _build\ndrwxr-xr-x@ 23 dbern staff 736 Jan 27 12:10 .\ndrwxr-x---+ 178 dbern staff 5696 Jan 27 12:09 ..\ndrwxr-xr-x@ 3 dbern staff 96 Jan 22 13:29 .git\n"
iex> ~BASH"echo 'uh oh' >&2 && echo 'heyo'"E # 'E' modifier executes and returns only stderr
"uh oh\n"
iex> ~BASH"echo { foo"
** (Bash.SyntaxError) [SC1056] Bash syntax error at line 1:
> 1 | echo { foo
^
hint: expected '}' to close brace groupUse as your Bash formatter.
# ./formatter.exs
[
plugins: [Bash.Formatter],
inputs: [
# ...
"**/*.{sh,bash}"
],
bash: [
indent_style: :spaces, # :spaces or :tabs (but you know which one is correct)
indent_width: 2, # number of spaces (ignored if :tabs)
line_length: 100 # max line length before wrapping
]
# ...
]Why Use This?
For DevOps & Infrastructure: Embed shell scripts in Elixir applications with
proper error handling, without shelling out to /bin/bash.
For Testing: Create reproducible shell environments with controlled state and captured output.
For Scripting: Write scripts that combine Bash's text processing with Elixir's power - call Elixir functions directly from Bash pipelines.
For fun: because YOLO
Features
| Feature | Description |
|---|---|
| Compile-time parsing | ~BASH and ~b sigil validates scripts at compile time with ShellCheck-compatible errors |
| Persistent sessions | Maintain environment variables, working directory, aliases, functions, and history |
| Elixir interop | Define Elixir functions callable from Bash using defbash |
| Full I/O support | Redirections, pipes, heredocs, process substitution |
| Job control | Background jobs, fg/bg switching, signal handling |
| Streaming output | Process stdout/stderr incrementally with configurable sinks |
Usage
Running Scripts
# Simple execution
{:ok, result, _} = Bash.run(~b"echo hello")
Bash.stdout(result)
#=> "hello\n"
# With environment variables
{:ok, result, _} = Bash.run(~b"echo $USER", env: %{"USER" => "alice"})
Bash.stdout(result)
#=> "alice\n"
# Multi-line scripts with arithmetic
{:ok, result, _} = Bash.run("""
x=5
y=10
echo $((x + y))
""")
Bash.stdout(result)
#=> "15\n"The Sigil
import Bash.Sigil
# Parse at compile time, execute at runtime
~BASH"echo hello"S # returns stdout string
~BASH"echo error >&2"E # returns stderr string
~BASH"echo hello" # returns %Bash.Script{} AST (no execution)
person = "world"
~BASH"echo 'Hello #{person}'"O # returns "Hello #{person}\n"
~b"echo 'Hello #{person}'"O # returns "Hello world\n"Sessions
Sessions maintain state across multiple commands:
{:ok, session} = Bash.Session.new()
# Set variables
Bash.run("export GREETING=hello", session)
# Use them later
{:ok, result, _} = Bash.run("echo $GREETING", session)
Bash.stdout(result)
#=> "hello\n"
# Working directory persists
Bash.run("cd /tmp", session)
{:ok, result, _} = Bash.run("pwd", session)
Bash.stdout(result)
#=> "/tmp\n"Elixir Interop
Define Elixir functions callable from Bash:
defmodule MyApp.BashAPI do
use Bash.Interop, namespace: "myapp"
defbash greet(args, _state) do
case args do
[name | _] ->
Bash.puts(:stderr, "uhoh!")
# Appended to stdout, and exits 0
{:ok, "Hello #{name}!\n"}
[] ->
# Appended to stderr, and exits 1
{:error, "usage: myapp.greet NAME"}
end
end
defbash upcase(_args, _state) do
Bash.stream(:stdin)
|> Stream.each(fn line ->
Bash.puts(String.upcase(String.trim(line)) <> "\n")
:ok
end)
|> Stream.run()
:ok
end
endLoad the API into a session:
# Option 1: Load at session creation
{:ok, session} = Bash.Session.new(apis: [MyApp.BashAPI])
# Option 2: Load into existing session
{:ok, session} = Bash.Session.new()
:ok = Bash.Session.load_api(session, MyApp.BashAPI)
# Now callable from Bash
{:ok, result, _} = Bash.run("myapp.greet World", session)
Bash.stdout(result)
#=> "Hello World!\n"
# Works in pipelines
{:ok, result, _} = Bash.run("echo hello | myapp.upcase", session)
Bash.stdout(result)
#=> "HELLO\n"Supported Features
Control Flow
if/then/elif/else/fiforloops (word lists and C-style)whileanduntilloopscasestatements&&,||,;operators- Command groups
{ }and subshells( )
Variables
- Simple variables (
$VAR,${VAR}) - Arrays (indexed and associative)
- Parameter expansion (
${VAR:-default},${VAR:+alt},${#VAR}, etc.) - Arithmetic expansion (
$((expr)))
Builtins
alias, bg, break, builtin, cd, command, continue, declare,
dirs, disown, echo, enable, eval, exec, exit, export, false,
fg, getopts, hash, help, history, jobs, kill, let, local,
mapfile, popd, printf, pushd, pwd, read, readonly, return,
set, shift, shopt, source, test, times, trap, true, type,
ulimit, umask, unalias, unset, wait
I/O
- Redirections (
>,>>,<,2>&1, etc.) - Pipelines
- Here documents and here strings
- Process substitution (
<(cmd),>(cmd))
Other
- Functions
- Brace expansion (
{a,b,c},{1..10}) - Glob patterns
- Quoting (single, double,
$'...') - Command substitution (
`cmd`and$(cmd))
Summary
Functions
Escape a string for safe interpolation within a Bash quoted context.
Get the exit code from an executed script or AST node.
Format a bash script string.
Format a file
Get the current session state within a defbash function.
Get all output (stdout + stderr) from an executed script, AST node, or session.
Parse a Bash script string into an AST.
Parse a Bash script file into an AST.
Write to stdout within a defbash function.
Write to stdout or stderr within a defbash function.
Executes a Bash script, AST, or string.
Execute a Bash script file.
Get stderr output from an executed script, AST node, or session.
Get stdout output from an executed script, AST node, or session.
Get stdin as a lazy stream within a defbash function.
Stream an enumerable to stdout or stderr within a defbash function.
Check if execution was successful (exit code 0).
Accumulate state update deltas within a defbash function.
Validate a Bash script without executing it.
Validate a Bash script file without executing it.
Execute a function with a session that is automatically stopped afterwards.
Functions
Escape a string for safe interpolation within a Bash quoted context.
This function ensures the string won't break out of its quote context.
It does NOT escape expansion characters like $ - users should choose
the appropriate quote type (single quotes for literal content, double
quotes when expansion is desired).
Arguments
string- the string to escapecontext- the quote context:?"- double quotes: escapes"and\?'- single quotes: escapes'using end/restart technique"DELIM"- heredoc: validates delimiter doesn't appear on its own line
Examples
# Double quotes - escape " and \
iex> Bash.escape!("say \"hello\"", ?")
"say \\\"hello\\\""
iex> Bash.escape!("path\\to\\file", ?")
"path\\\\to\\\\file"
# Single quotes - escape ' using end/restart technique
iex> Bash.escape!("it's here", ?')
"it'\\''s here"
# Heredoc - validates delimiter doesn't appear
iex> Bash.escape!("safe content", "EOF")
"safe content"Raises
Raises Bash.EscapeError if the string cannot be safely escaped,
such as when a heredoc delimiter appears on its own line.
Bash.escape!("line1\nEOF\nline2", "EOF")
#=> raises Bash.EscapeError
@spec exit_code(Bash.ExecutionResult.t() | {atom(), Bash.ExecutionResult.t(), pid()}) :: non_neg_integer() | nil
Get the exit code from an executed script or AST node.
Accepts either a result struct or a result tuple for pipe chaining.
Examples
{:ok, script, _} = Bash.run("exit 42")
Bash.exit_code(script)
#=> 42
# Pipe-friendly
Bash.run("exit 42") |> Bash.exit_code()
#=> 42
Format a bash script string.
Returns the formatted script as a string. If parsing fails, the original content is returned unchanged.
See Bash.Formatter for more details.
Options
:indent_style- Either:spacesor:tabs. Defaults to:spaces.:indent_width- Number of spaces per indent level (when using spaces). Defaults to2.:line_length- Maximum line length. Defaults to80.
Examples
Bash.format("if [ -f foo ];then\necho bar\nfi")
#=> "if [ -f foo ]; then\n echo bar\nfi\n"
Bash.format("if true;then\necho ok\nfi", bash: [indent_style: :tabs])
#=> "if true; then\n\techo ok\nfi\n"
Format a file
For options, see format/2
Get the current session state within a defbash function.
This function is only valid inside defbash function bodies.
Examples
defbash show_var(args, _state) do
state = Bash.get_state()
var_name = List.first(args)
value = get_in(state, [:variables, var_name])
Bash.puts("#{var_name}=#{inspect(value)}\n")
:ok
end
@spec output( Bash.ExecutionResult.t() | {atom(), Bash.ExecutionResult.t(), pid()} | pid() ) :: String.t()
Get all output (stdout + stderr) from an executed script, AST node, or session.
Accepts:
- A result struct
A result tuple
{:ok | :error, result, session}for pipe chaining- A session PID to get accumulated output
Examples
{:ok, script, _} = Bash.run("echo out; echo err >&2")
Bash.output(script)
#=> "out\nerr\n"
# Pipe-friendly
Bash.run("echo out; echo err >&2") |> Bash.output()
#=> "out\nerr\n"
# From session
{:ok, session} = Bash.Session.new()
Bash.run("echo out; echo err >&2", session)
Bash.output(session)
#=> "out\nerr\n"
@spec parse(String.t()) :: {:ok, Bash.Script.t()} | {:error, Bash.SyntaxError.t()}
Parse a Bash script string into an AST.
Returns {:ok, %Script{}} on success, or {:error, %SyntaxError{}} on failure.
Examples
iex> {:ok, script} = Bash.parse("echo hello")
iex> script.statements
[%Bash.AST.Command{name: "echo", args: ["hello"]}]
iex> {:error, %Bash.SyntaxError{}} = Bash.parse("if true")
# Missing 'then' and 'fi'
@spec parse_file(Path.t()) :: {:ok, Bash.Script.t()} | {:error, Bash.SyntaxError.t() | File.posix()}
Parse a Bash script file into an AST.
Reads the file and parses its contents. Returns {:ok, %Script{}} on success,
or {:error, reason} on failure (either file read error or syntax error).
Examples
iex> {:ok, script} = Bash.parse_file("script.sh")
iex> script.statements
[%Bash.AST.Command{...}]
iex> {:error, %Bash.SyntaxError{}} = Bash.parse_file("invalid.sh")
iex> {:error, :enoent} = Bash.parse_file("missing.sh")
Write to stdout within a defbash function.
This function is only valid inside defbash function bodies.
Examples
defbash greet(args, _state) do
name = List.first(args, "world")
Bash.puts("Hello #{name}!\n")
:ok
end
Write to stdout or stderr within a defbash function.
This function is only valid inside defbash function bodies.
Examples
defbash example(_args, _state) do
Bash.puts(:stdout, "normal output\n")
Bash.puts(:stderr, "error output\n")
:ok
end
Executes a Bash script, AST, or string.
Accepts:
script: Can be:- A string - will be parsed and executed
- An AST struct (Script, Command, Pipeline, etc.) - will be executed in sequence
A result tuple
{:ok | :error | :exit, result, session}- continues with that session
session_or_opts: Can be:nil- creates a new session with default options- A PID - uses an existing session
- Keyword list - creates a new session with these initialization options
opts: Execution options:await: true|false- whether to wait for result (default: true)
Returns:
- When
await: true(default):{:ok, result, session_pid}or{:error, result, session_pid} - When
await: false:{:ok, session_pid}
Examples
# Execute a string
{:ok, result, session_pid} = Bash.run("echo hello")
# Execute an AST
{:ok, result, session_pid} = Bash.run(~BASH"echo hello")
# Execute a multi-line script
{:ok, result, session_pid} = Bash.run("""
x=5
y=10
echo $x $y
""")
# With existing session PID
{:ok, session} = Session.new()
{:ok, result, ^session} = Bash.run(~BASH"echo hello", session)
# With session initialization options
{:ok, result, session_pid} = Bash.run(~BASH"echo $USER", env: %{"USER" => "alice"})
# Async execution
{:ok, ref, session_pid} = Bash.run(~BASH"sleep 10", nil, await: false)
# Pipe-friendly chaining - continues with the same session
Bash.run("x=5")
|> Bash.run("echo $x")
|> Bash.stdout()
#=> "5\n"
@spec run_file(Path.t(), pid() | keyword() | map() | nil, keyword()) :: {:ok, term(), pid()} | {:error, term(), pid() | nil} | {:exit, term(), pid()} | {:exec, term(), pid()}
Execute a Bash script file.
Reads and parses the file, then executes it. Accepts the same session and
execution options as run/3.
Returns:
{:ok, result, session_pid}on success{:error, result, session_pid}on execution error{:error, %SyntaxError{}, nil}on parse error{:error, posix_error, nil}on file read error
Examples
# Execute a script file
{:ok, result, session_pid} = Bash.run_file("script.sh")
# With session options
{:ok, result, session_pid} = Bash.run_file("script.sh", env: %{"DEBUG" => "1"})
# With existing session
{:ok, session} = Bash.Session.new()
{:ok, result, ^session} = Bash.run_file("script.sh", session)
@spec stderr( Bash.ExecutionResult.t() | {atom(), Bash.ExecutionResult.t(), pid()} | pid() ) :: String.t()
Get stderr output from an executed script, AST node, or session.
Accepts:
- A result struct
A result tuple
{:ok | :error, result, session}for pipe chaining- A session PID to get accumulated stderr
Examples
{:ok, script, _} = Bash.run("echo error >&2")
Bash.stderr(script)
#=> "error\n"
# Pipe-friendly
Bash.run("echo error >&2") |> Bash.stderr()
#=> "error\n"
# From session
{:ok, session} = Bash.Session.new()
Bash.run("echo error >&2", session)
Bash.stderr(session)
#=> "error\n"
@spec stdout( Bash.ExecutionResult.t() | {atom(), Bash.ExecutionResult.t(), pid()} | pid() ) :: String.t()
Get stdout output from an executed script, AST node, or session.
Accepts:
- A result struct
A result tuple
{:ok | :error, result, session}for pipe chaining- A session PID to get accumulated stdout
Examples
{:ok, script, _} = Bash.run("echo hello")
Bash.stdout(script)
#=> "hello\n"
# Pipe-friendly
Bash.run("echo hello") |> Bash.stdout()
#=> "hello\n"
# From session
{:ok, session} = Bash.Session.new()
Bash.run("echo hello", session)
Bash.stdout(session)
#=> "hello\n"
Get stdin as a lazy stream within a defbash function.
This function is only valid inside defbash function bodies.
Returns an empty stream if no stdin is available.
Examples
defbash upcase(_args, _state) do
Bash.stream(:stdin)
|> Stream.each(fn line ->
Bash.puts(String.upcase(line))
end)
|> Stream.run()
:ok
end
Stream an enumerable to stdout or stderr within a defbash function.
This function is only valid inside defbash function bodies.
Examples
defbash generate(_args, _state) do
stream = Stream.map(1..5, &"#{&1}\n")
Bash.stream(:stdout, stream)
:ok
end
defbash errors(_args, _state) do
Bash.stream(:stderr, ["warning 1\n", "warning 2\n"])
:ok
end
@spec success?(Bash.ExecutionResult.t() | {atom(), Bash.ExecutionResult.t(), pid()}) :: boolean()
Check if execution was successful (exit code 0).
Accepts either a result struct or a result tuple for pipe chaining.
Examples
{:ok, script, _} = Bash.run("true")
Bash.success?(script)
#=> true
# Pipe-friendly
Bash.run("true") |> Bash.success?()
#=> true
Accumulate state update deltas within a defbash function.
Accepts a map or keyword list of update keys. Updates are accumulated as deltas and applied after execution completes.
This function is only valid inside defbash function bodies.
Examples
defbash set_var([name, value], _state) do
Bash.update_state(%{variables: %{name => Bash.Variable.new(value)}})
:ok
end
@spec validate(String.t()) :: :ok | {:error, Bash.SyntaxError.t()}
Validate a Bash script without executing it.
Parses the script and returns :ok if valid, or {:error, %SyntaxError{}}
if the script has syntax errors.
Examples
iex> Bash.validate("echo hello")
:ok
iex> {:error, %Bash.SyntaxError{}} = Bash.validate("if true")
# Missing 'then' and 'fi'
@spec validate_file(Path.t()) :: :ok | {:error, Bash.SyntaxError.t() | File.posix()}
Validate a Bash script file without executing it.
Reads the file and validates its contents. Returns :ok if valid,
or {:error, reason} on failure (either file read error or syntax error).
Examples
iex> Bash.validate_file("valid_script.sh")
:ok
iex> {:error, %Bash.SyntaxError{}} = Bash.validate_file("invalid.sh")
iex> {:error, :enoent} = Bash.validate_file("missing.sh")
Execute a function with a session that is automatically stopped afterwards.
Creates a new session, passes it to the function, and ensures the session is stopped when the function returns or raises. Returns whatever the function returns.
Examples
# Simple usage
result = Bash.with_session(fn session ->
{:ok, result, _} = Bash.run("echo hello", session)
Bash.stdout(result)
end)
#=> "hello\n"
# With session options
result = Bash.with_session([env: %{"USER" => "alice"}], fn session ->
{:ok, result, _} = Bash.run("echo $USER", session)
Bash.stdout(result)
end)
#=> "alice\n"
# With APIs
Bash.with_session([apis: [MyApp.BashAPI]], fn session ->
Bash.run("myapp.greet World", session)
end)