Skip to content

Command Execution

10.1 Statement Context (Output to Terminal)

When a command appears as a statement (not in an expression), its output goes directly to the terminal.

# Commands print to stdout/stderr
ls -la
echo "Hello, World"
find . -name "*.txt"

# Side effects execute
rm -f temp.txt
mkdir -p new_directory
git commit -m "Update"

# Pipeline statements
ps aux | grep nginx
cat file.txt | sort | uniq

# In blocks
if needsUpdate then {
    echo "Updating..."
    git pull
    make build
}

10.2 Expression Context (Capture Output)

When a command is part of an expression (assignment, function argument, etc.), its stdout is captured.

# Assignment captures stdout
let files = ls -la
let count = wc -l < README.md
let user = whoami
let today = date +%Y-%m-%d

# Trailing newline is trimmed
let name = echo "test"            # "test" not "test\n"

# Capture in expressions
let greeting = $"Hello, {whoami}!"
let info = $"Files: {ls | wc -l}"

# Captured output as list
let fileList = ls | lines
let nonEmpty = cat file.txt | lines |> filter (fun l -> l != "")

# In conditionals
if $(grep -q pattern file.txt) then
    echo "Pattern found"

# As function arguments
process (cat config.txt)
analyze (git diff HEAD~1)

10.3 String Interpolation

# Variable interpolation
let name = "World"
echo "Hello, $name"               # Hello, World
echo "Path: ${HOME}/docs"         # Path: /home/user/docs

# Expression interpolation
echo "Sum: $((1 + 2 * 3))"        # Sum: 7
echo "Files: $(ls | wc -l)"       # Files: 42
echo "Upper: ${name |> toUpper}"  # Upper: WORLD

# Nested interpolation
let user = "alice"
echo "Home: ${getenv "HOME_$user"}"

# Escape to prevent interpolation
echo "Literal \$name"             # Literal $name
echo "Price: \$99.99"             # Price: $99.99

# Single quotes: no interpolation
echo 'No $interpolation here'     # No $interpolation here
echo 'Path: $HOME'                # Path: $HOME

10.4 Redirections

# Output redirection
echo "log entry" > logfile.txt    # Overwrite
echo "more" >> logfile.txt        # Append

# Input redirection
sort < unsorted.txt
wc -l < README.md

# Stderr redirection
command 2> errors.txt             # Stderr to file
command 2>> errors.txt            # Append stderr
command 2>&1                      # Stderr to stdout

# Combined redirects
command > output.txt 2>&1         # Both to file
command &> all.txt                # Shorthand for above
command 2>&1 | tee log.txt        # Both to pipe

# Discard output
command > /dev/null               # Discard stdout
command 2> /dev/null              # Discard stderr
command &> /dev/null              # Discard both

# Here documents
cat <<EOF
This is a multi-line
here document with $name interpolation
and $(command) substitution
EOF

# Here document without interpolation
cat <<'EOF'
No interpolation here
$VAR stays as literal $VAR
$(cmd) stays as literal
EOF

# Here strings
cat <<< "Single line input"
grep pattern <<< $variable
wc -w <<< "count these words"

10.5 Process Substitution

Treat command output as a file.

# Compare output of two commands
diff <(ls dir1) <(ls dir2)
diff <(sort file1) <(sort file2)

# Use command output as input file
while read line do
    process $line
end < <(find . -name "*.txt")

# Multiple process substitutions
paste <(cut -f1 file1) <(cut -f2 file2)

# Output process substitution
tee >(gzip > backup.gz) < input.txt

# Complex example
comm -12 <(sort users_today | uniq) <(sort users_yesterday | uniq)

Windows

Process substitution is not yet available on Windows. On Linux and macOS, it is implemented using /dev/fd paths. A future release may add Windows support via named pipes. See Platform Differences for details.

10.6 Command Substitution

# $() syntax (preferred)
let user = $(whoami)
let files = $(ls *.txt)
echo "Today is $(date +%A)"

# Backtick syntax (legacy, supported)
let user = `whoami`
echo "Today is `date +%A`"

# Nested substitution (only works with $())
let result = $(cat $(find . -name "config.txt" | head -1))

# Arithmetic substitution
let sum = $((1 + 2 * 3))
let next = $((counter + 1))
echo "Result: $((a * b + c))"

$(...) in F# Expressions

$(...) also works as a self-delimiting F# expression that captures stdout as a string. Unlike & command which is greedy (consumes up to a statement boundary), $(...) composes naturally inline:

# Self-delimiting — composes with operators
let greeting = $(whoami) + "@" + $(hostname)

# In if-conditions
if $(git status --porcelain) == "" then print "clean" else print "dirty"

# As pipeline source
$(echo 42) |> string_length |> print   # prints 2

# Compare with & command (needs parentheses for inline use)
let greeting = (& whoami) + "@" + (& hostname)

10.7 Dynamic Command Execution (exec)

The exec keyword executes a dynamically-resolved program path with F# expression arguments. Unlike shell commands where the program name is a compile-time literal, exec takes runtime string values — enabling conditional command dispatch via which and pattern matching.

# Single command with literal path
exec "/usr/bin/fortune"

# With arguments
exec "/usr/bin/fortune" "-s"

# Pipeline via | — true OS-level streaming pipes
exec "/usr/bin/fortune" | exec "/usr/bin/lolcat"

# Three-stage pipeline
exec "/bin/echo" "hello" | exec "/usr/bin/tr" "a-z" "A-Z" | exec "/bin/cat"

# Variable program paths (the main use case)
let f = "/usr/bin/fortune"
let l = "/usr/bin/lolcat"
exec f | exec l

# F# expression arguments
let flags = "-s"
exec f flags

# The motivating use case: which + match + exec
match which "fortune", which "lolcat" with
| Some f, Some l -> (exec f | exec l)
| Some f, None   -> exec f
| _              -> println "Commands not found"

Key differences from shell | pipes: - Program paths and arguments are F# expressions (variables, function calls, string literals) - True OS-level pipe semantics (stdout→stdin streaming) - Inside match arms, parentheses are needed: (exec f | exec l) to disambiguate | from arm separators - Returns the exit code of the last command in the pipeline

10.6 Multi-line Commands

Long shell commands can span multiple lines. Endo recognises three forms of line continuation. Bash-style \ at end of line is not supported.

Trailing operator — if a line ends in |, |>, &&, or ||, the command continues on the next line (indentation is irrelevant):

# Trailing `|`
ls -la |
    grep ".endo" |
    wc -l

# Trailing `&&` / `||`
make build &&
    make test

# Trailing `|>` (F# pipeline)
readFile "data.json" |>
    parseJson |>
    validate

Leading operator — if the next line starts with |, |>, &&, or || (at any column), it continues the previous command. This is idiomatic F#:

[1; 2; 3]
    |> List.map (fun x -> x * 2)
    |> List.filter (fun x -> x > 2)
    |> List.sum

ls -la
    | grep ".endo"
    | wc -l

testsPassed
    && publishArtifact
    || notifyFailure

Indentation — if the next line's first token sits at a column strictly greater than the program name's column, it is parsed as a continuation of the command above, regardless of operators:

git commit -m "blurb"
           --author "John Doe"
           --date "2024-01-01"

Here git is at column 1; the --author and --date lines are indented past column 1, so they extend the git commit invocation.

The three forms can be combined freely. A command at column 1 followed by a line at column 1 starts a new, independent command.


See also: Operators & Pipelines | Error Handling | Interoperability