Shell scripting in Elm

elm script <file.elm> [args…] runs an Elm file as a command-line script on the JIT interpreter — a from-scratch take on elm-posix. A script exposes a main : Posix.Io value that *describes* a sequence of effects as data; the runner walks that description and performs the real I/O, then exits with the script's status code.

The Posix module

Build the Io description with these helpers (effects that produce a value take a continuation, so scripts read in continuation-passing style):

HelperTypeEffect
printString -> Io -> IoWrite a line to stdout, then continue.
readLine(String -> Io) -> IoRead a line from stdin (empty at EOF).
readFileString -> (Result String String -> Io) -> IoRead a whole file.
writeFileString -> String -> Io -> IoWrite a string to a file.
getArgs(List String -> Io) -> IoThe process arguments.
getEnvString -> (Maybe String -> Io) -> IoAn environment variable.
listDirString -> (Result String (List String) -> Io) -> IoA directory's entries.
exitInt -> IoExit with a status code.
doneIoFinish successfully (exit 0).

Hello, args

module Main exposing (main)

import Posix exposing (..)

main : Io
main =
    getArgs (\args -> print ("hello " ++ String.join " " args) done)
elm script hello.elm world      # prints: hello world

A worked example: word count

The bundled WordCount.elm is a wc-style counter — it reads each file argument, counts lines/words/characters, prints a line per file and a total, and handles missing files with a non-zero exit:

elm script WordCount README.md docs/scripting.md

The name WordCount resolves to the bundled demo; you can also pass any path to your own script.

Structured shell commands (the Bash module)

The bundled Bash module adds common shell commands that return structured Elm values instead of text you have to re-parse — ls/find give Entry records, grep gives Match records, wc gives a Counts record, and exec gives a Proc:

CommandResultLike
ls / findList Entry { name, path, isDir, size, modified }ls / find
grepList Match { lineNumber, line }grep
wcCounts { lines, words, chars }wc
head / tail / sort / uniqList Stringthe same
statEntrystat
duInt (bytes)du -s
touch / mkdir / rm / cp / mvString (the path)the same
pwd / which / envpath / Maybe path / List (String, String)the same
execProc { exitCode, stdout, stderr }run a process

Because the results are typed, you process them with ordinary list/record code. For example, list a directory and total the size of its files:

module Main exposing (main)

import Bash exposing (..)

main : Io
main =
    ls "." (\result ->
        case result of
            Ok entries ->
                let
                    files = List.filter (\e -> not e.isDir) entries
                    total = List.foldl (\e acc -> acc + e.size) 0 files
                in
                print (String.fromInt (List.length files) ++ " files, " ++ String.fromInt total ++ " bytes") done

            Err message ->
                print ("error: " ++ message) (exit 1)
    )

The bundled FolderReport.elm goes further — it finds a directory recursively and prints a report from the structured entries:

elm script FolderReport src/main/elm/lib
Folder report for src/main/elm/lib
----------------------------------------
Files:        7
Directories:  1
Total size:   28 KB

Largest files:
  Posix.elm (6 KB)
  Bash.elm (5 KB)
  Server.elm (3 KB)
  Site.elm (3 KB)
  Test.elm (2 KB)

By extension:
  .elm: 7

And exec runs an external process, handing back its exit code and captured output:

exec "git" [ "rev-parse", "--short", "HEAD" ] (\result ->
    case result of
        Ok proc -> print ("HEAD is " ++ String.trim proc.stdout) done
        Err message -> print message (exit 1)
)
HEAD is 7a6146c

Text-processing libraries (Awk, M4, Csv)

These bundled libraries help Elm scripts build text for the classic Unix tools. Awk and M4 *compose the tool's own source* (an awk program / m4 macros) as plain String values you embed in a shell script or pass on a command line — they don't reimplement awk/m4. Csv parses and encodes CSV data. All are plain functions — no Io — so they compose inside any handler and are easy to test.

Awk

Awk builds awk program text to embed in a generated shell script or pass to awk — it doesn't run awk. An awk program is a list of pattern { action } rules: begin/end/on cond/matchLine re/eachLine. program renders them, oneLiner single-quotes the result for a command line, and invocation builds the awk argument list. The expression helpers produce awk text: field 1 is $1, nf/nr, print/printf/assign/addTo, call for functions (substr, toupper, gsub, …) and matches for ~ /re/.

import Awk exposing (..)
import Bash

-- awk 'BEGIN { FS="," } { s += $2 } END { print s }' data.csv
sumCommand : List String
sumCommand =
    invocation
        [ begin [ assign "FS" (str ",") ]
        , eachLine [ addTo "s" (field 2) ]
        , end [ print [ var "s" ] ]
        ]
        [ "data.csv" ]

run : Bash.Io
run =
    Bash.exec "awk" sumCommand (\_ -> Bash.done)

The bundled AwkSum.elm demo prints the awk command to sum a column: elm script AwkSum.elm 2 sales.csv.

M4

M4 builds m4 macro source to save as a .m4 file or pipe to m4 — it doesn't run m4. define/undefine write quoted definitions, call writes an invocation, and program joins statements into a document. Inside a body, arg 1 is $1, args is $*, argCount is $# and macroName is $0; ifelse/ifdef/eval/include write those builtins, quote adds ` …' ` quoting and dnl` swallows a trailing newline.

import M4

-- define(`greet', `Hello $1!')dnl
-- greet(`world')
out : String
out =
    M4.program
        [ M4.define "greet" ("Hello " ++ M4.arg 1 ++ "!") ++ M4.dnl
        , M4.call "greet" [ M4.quote "world" ]
        ]

The bundled M4Expand.elm demo emits such a program: elm script M4Expand.elm world (pipe it to m4 to get Hello world!).

Csv

Csv parses and encodes RFC-4180 CSV: parse returns rows of fields (honouring quoted fields with embedded commas/newlines), encode is the inverse, and parseWithHeader pairs each row with the header columns (records, looked up with get).

import Csv

-- [["a","b,c"],["1","2"]]
rows : List (List String)
rows =
    Csv.parse "a,\"b,c\"\n1,2"

CsvReport.elm renders a CSV as an HTML table page (Csv + Site): elm script CsvReport.elm people.csv > report.html.

How it runs

The handler is pure data, so a script is trivial to test: build the Io value and walk it with a fake stdin/stdout. The runner (ScriptRunner) performs print/readLine/readFile/writeFile against real streams and the filesystem, applies each continuation to the result, and returns the exit code from exit/done.