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 group

Use 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

FeatureDescription
Compile-time parsing~BASH and ~b sigil validates scripts at compile time with ShellCheck-compatible errors
Persistent sessionsMaintain environment variables, working directory, aliases, functions, and history
Elixir interopDefine Elixir functions callable from Bash using defbash
Full I/O supportRedirections, pipes, heredocs, process substitution
Job controlBackground jobs, fg/bg switching, signal handling
Streaming outputProcess 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
end

Load 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/fi
  • for loops (word lists and C-style)
  • while and until loops
  • case statements
  • &&, ||, ; 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.

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.

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(string, context)

@spec escape(String.t(), integer() | String.t()) ::
  {:ok, String.t()} | {:error, EscapeError.t()}

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 escape
  • context - 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

escape!(string, context)

@spec escape!(String.t(), integer() | String.t()) :: String.t()

exit_code(result)

@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(content, opts \\ [])

@spec format(String.t(), Keyword.t()) :: String.t()

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 :spaces or :tabs. Defaults to :spaces.
  • :indent_width - Number of spaces per indent level (when using spaces). Defaults to 2.
  • :line_length - Maximum line length. Defaults to 80.

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_file(file, opts \\ [])

@spec format_file(Path.t(), Keyword.t()) :: :ok

Format a file

For options, see format/2

get_state()

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

output(session)

@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"

parse(script)

@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'

parse_file(path)

@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")

puts(message)

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

puts(atom, message)

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

run(script)

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"

run(script, script)

run(script, script, opts)

run_file(path, session_or_opts \\ nil, opts \\ [])

@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)

stderr(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"

stdout(session)

@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"

stream(source)

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(target, enumerable)

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

success?(result)

@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

update_state(updates)

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

validate(script)

@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'

validate_file(path)

@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")

with_session(fun)

@spec with_session((pid() -> result)) :: result when result: term()

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)

with_session(opts, fun)

@spec with_session(
  keyword(),
  (pid() -> result)
) :: result
when result: term()