CI License: MIT

POSIX-like shell word splitting, escaping, and joining for Elixir — the practical equivalent of Python's shlex.split/quote/join and Ruby's Shellwords, with zero runtime dependencies.

ShellWords.split(~S(cp "my file.txt" /tmp))
# {:ok, ["cp", "my file.txt", "/tmp"]}

ShellWords.join(["git", "commit", "-m", "initial commit"])
# "git commit -m 'initial commit'"

Installation

Add shell_words to your dependencies in mix.exs:

def deps do
  [
    {:shell_words, "~> 0.1.0"}
  ]
end

Usage

split/2

Parses a shell-like command string into words. Returns {:ok, words} or {:error, %ShellWords.ParseError{}} with a reason and a 0-based byte position.

ShellWords.split(~S(echo "hello world"))
# {:ok, ["echo", "hello world"]}

ShellWords.split(~S(echo hello\ world))
# {:ok, ["echo", "hello world"]}

ShellWords.split(~S(echo "hello))
# {:error, %ShellWords.ParseError{reason: :unterminated_double_quote, position: 5, ...}}

Supported syntax: whitespace-separated words (ASCII space, tab, newline, carriage return); single quotes (fully literal); double quotes with POSIX backslash semantics (\ escapes $, `, ", \; any other backslash sequence passes through literally); backslash escaping outside quotes; empty quoted words; adjacent segment concatenation.

split!/2

Same as split/2 but returns the list directly and raises ShellWords.ParseError on invalid input.

ShellWords.split!(~S(echo "hello world"))
# ["echo", "hello world"]

escape/1

Escapes one argument so a POSIX shell parses it back as a single word with the original content. Safe bare words (non-empty, only A-Z a-z 0-9 @ % + = : , . / _ -) pass through unchanged; everything else is single-quoted. The empty string becomes ''.

ShellWords.escape("hello")        # "hello"
ShellWords.escape("hello world")  # "'hello world'"
ShellWords.escape("don't")        # ~S('don'"'"'t')
ShellWords.escape("привет")       # ~S('привет')

join/1

Escapes each argument and joins with spaces.

ShellWords.join(["echo", "hello world"])
# "echo 'hello world'"

Security Notes

Prefer argv-based command execution when possible. Use ShellWords.escape/1 or ShellWords.join/1 only when you need to build a shell command string.

# Safest: no shell involved
System.cmd("echo", ["hello world"])

# When a shell string is unavoidable, escape every argument
System.cmd("sh", ["-c", ShellWords.join(["echo", user_input])])

The core guarantee, verified with property-based tests over arbitrary UTF-8 input, is the round trip:

ShellWords.split(ShellWords.join(argv)) == {:ok, argv}

ShellWords targets POSIX-like shells. It does not provide Windows cmd.exe or PowerShell escaping.

What This Library Does Not Do

No command execution, pipes, redirects, variable expansion ($HOME), command substitution ($(...)), globbing (*.txt), tilde expansion, heredocs, or Bash-specific syntax. Comments are not parsed in v0.1.0: # is an ordinary character (a comments: option may be added in a future version). Backslash-newline line continuation is not supported. Behavior on invalid UTF-8 is unspecified.

Comparison with Python shlex and Ruby Shellwords

PythonRubyShellWords
Splitshlex.split(s)Shellwords.split(s)ShellWords.split(s) / split!(s)
Escapeshlex.quote(s)Shellwords.escape(s)ShellWords.escape(s)
Joinshlex.join(argv)Shellwords.join(argv)ShellWords.join(argv)

The function is named escape/1 (not quote/1) because quote is an Elixir special form.

Escaping style differs from Ruby. Ruby's Shellwords.escape produces backslash-style output (It\'s\ better); ShellWords.escape/1 produces single-quote wrapping like Python's shlex.quote ('It'"'"'s better'). Both parse back to the original argument in a POSIX shell — the output just looks different.

License

MIT. See LICENSE.