Pitcrew Language Tour

A DSL for managing computers, oriented around running commands and executing programs over SSH.

Basics

Pitcrew files use the .pit extension. Programs are a sequence of top-level statements. There is no main function — execution begins at the first statement.

name := "world"
print("hello " + name)

print() is a built-in function that writes to stderr. It accepts any number of arguments.

Variables

Declaration with inference

Use := to declare a variable. The type is inferred from the right-hand side.

x := 42
name := "Alice"
active := true

Explicit variable declaration

Use var to declare with an explicit type, optionally with a default value.

var count int
var label string = "default"

Constants

Constants are immutable and declared with const.

const MAX = 100
const PREFIX string = "v"

Assignment

Reassign with =.

x := 1
x = 2

Strings

Double-quoted strings

Regular strings with support for #{} interpolation.

greeting := "hello"
msg := "Hi #{name}, you are #{age} years old"

String concatenation

full := "hello" + " " + "world"

Backtick strings (command execution)

Backtick strings execute shell commands and return stdout as a string. They also support interpolation.

files := `ls -la`
result := `echo #{value}`

Interpolated values in backtick strings are automatically shell-escaped for safety.

Operators

Arithmetic

a := 10 + 5     # addition
b := 10 - 5     # subtraction
c := 10 * 5     # multiplication
d := 10 / 5     # division
e := 10 % 3     # modulo

Comparison

x == y          # equal
x != y          # not equal
x < y           # less than
x > y           # greater than
x <= y          # less than or equal
x >= y          # greater than or equal

Logical

a && b          # and
a || b          # or

Increment / Decrement

i++
i--

Pipe

The pipe operator |> passes the left-hand value as the first argument to the right-hand function call. See the Pipes section for details.

"hello" |> len           # same as len("hello")

Precedence (low to high)

Control Flow

If / else

if x == 1 {
    print("one")
} else if x == 2 {
    print("two")
} else {
    print("other")
}

If as an expression

if returns the value of its last expression, so it can be used on the right-hand side of a declaration.

label := if count == 1 {
    "item"
} else {
    "items"
}

While loop

i := 0
while i < 5 {
    print(i)
    i++
}

For loop

for i := 0; i < 10; i++ {
    print(i)
}

Break and continue

for i := 0; i < 10; i++ {
    if i == 3 {
        continue
    }
    if i == 7 {
        break
    }
    print(i)
}

Functions

Basic function

fn add(a int, b int) int {
    a + b
}

result := add(3, 4)

The last expression in a function body is its return value. No return keyword needed.

Early return

fn check(x int) string {
    if x == 0 {
        return "zero"
    }
    "nonzero"
}

Named arguments

Arguments after the positional arguments can be named with name: type syntax in the definition.

fn greet(msg string, loud: bool) string {
    if loud {
        msg + "!"
    } else {
        msg
    }
}

greet("hello", loud: true)

Scope capture

Functions capture outer variables as read-only constants. Outer variables cannot be modified from inside a function.

factor := 10
fn scale(x int) int {
    x * factor   # factor is read-only here
}

Structs

Definition

struct person {
    name string
    age int
}

Instantiation

Create instances using the struct name as a function, with named arguments.

p := person(name: "Alice", age: 30)

Field access

print(p.name)
print(p.age)

Spread operator

Create a new struct from an existing one, overriding specific fields. The original is not modified.

older := person(p..., age: p.age + 1)

Default field values

struct config {
    port int = 8080
    host string = "localhost"
}

c := config()  # uses defaults

Type System

Primitive types

Optional types

Append ? to mark a type as nullable.

fn find(id int) string? {
    # may return a string or nil
}

Array types

Use square brackets around the element type.

fn sum(nums [int]) int {
    # ...
}

Type aliases

type UserId int
type Name string

Pitcrew uses static type checking before execution. All types are verified at compile time.

Error Handling

Pitcrew uses typed errors instead of exceptions. Functions declare error types with ! after the error type.

Declaring errors

fn parse(s string) string! int {
    if s == "" {
        error "empty input"
    }
    42
}

The syntax is error_type! return_type. Here the function can return an int or raise a string error.

Catching errors

Use catch after a function call to handle errors.

result := parse("") catch {|err|
    print("Error: " + err)
    0
}

The type checker enforces that all errors are either caught or propagated. You cannot ignore an error.

Collections

Arrays

nums := [1, 2, 3, 4]
first := nums[0]
nums[0] = 10        # mutation

Maps

ages := {"alice": 30, "bob": 25}
a := ages["alice"]

Command Execution

Backtick strings execute shell commands and return their stdout as a string.

hostname := `hostname`
files := `ls -la /tmp`

Values interpolated into backtick strings are automatically shell-escaped:

dir := "/path with spaces"
listing := `ls #{dir}`

Detailed command results

Use $ before a backtick command to get a struct with stdout, stderr, and status instead of just stdout.

result := $`ls /nonexistent`
print(result.status)     # exit code (e.g. 2)
print(result.stderr)     # error message
print(result.stdout)     # stdout output

Without $, a non-zero exit code raises a runtime error. With $, you get the full result and can handle errors yourself.

Commands run in the local shell. To run commands on remote machines, see the SSH section.

Heredoc Strings

Use >> at the start of a line to write multi-line string content. Each continuation line must start with >> at the same column.

config :=
    >> server {
    >>     listen 80;
    >>     server_name #{domain};
    >> }

Heredoc strings support #{} interpolation. Quotes and backticks are treated as regular content — no escaping needed.

html :=
    >> <div class="container">
    >>     <h1>#{title}</h1>
    >> </div>

All >> markers in a heredoc must be aligned to the same column. Misaligned lines produce a parse error.

SSH

SSH is a language primitive. The ssh keyword sends a function call to a remote machine for execution.

fn get_uptime() string {
    `uptime`
}

result := ssh get_uptime(), host: "10.0.0.1", user: "deploy"
print(result)

When an ssh expression runs:

The function and all values it captures are serialized. Outer variables become read-only constants on the remote side.

Pipe Operator

The pipe operator |> passes the left-hand value as the first argument to the right-hand function call. This enables readable left-to-right chaining instead of deeply nested calls.

Basic usage

Parentheses are optional when there are no additional arguments.

# These are equivalent:
len("hello")
"hello" |> len

With additional arguments

When the function takes more arguments, the piped value is prepended before them.

# These are equivalent:
join([1, 2, 3], ", ")
[1, 2, 3] |> join(", ")

Chaining

Pipes are left-associative, so chains read naturally from left to right.

# Nested style:
join(map([1, 2, 3]) {|x| x + 1}, ", ")

# Piped style:
[1, 2, 3] |> map {|x| x + 1} |> join(", ")  # "2, 3, 4"

With blocks

Block arguments work naturally with pipes. Parentheses are optional when the only argument is the piped value.

[1, 2, 3] |> map {|x| x * 2}   # [2, 4, 6]

Multi-step pipelines

"  hello world  " |> trim |> split(" ") |> len  # 2

The pipe operator has the lowest precedence of all operators, so 1 + 2 |> f pipes 3 into f.

Async & Concurrency

Pitcrew supports asynchronous execution with async and resolve. Use async to run an expression concurrently, producing a promise. Use resolve to wait for the result.

Basic async

promise := async `long-running-command`
# do other work while it runs...
result := resolve(promise)  # wait for completion

Parallel execution

Start multiple tasks concurrently, then wait for all of them.

a := async `curl http://api1.example.com`
b := async `curl http://api2.example.com`
c := async `curl http://api3.example.com`

r1 := resolve(a)
r2 := resolve(b)
r3 := resolve(c)

Concurrency limiting

Use sync.limiter to control how many async tasks run at the same time.

import "sync"

limit := sync.limiter(3)   # max 3 concurrent tasks

promises := ["host1", "host2", "host3", "host4", "host5"] |> map {|host|
    async(limit) `ping -c 1 #{host}`
}

Promises cannot be passed across SSH boundaries. Resolve them before sending values to a remote host.

Regular Expressions

Regex literals use forward-slash syntax, similar to Ruby. The regex module provides matching and searching.

Regex literals

pattern := /[0-9]+/

Matching and finding

import "regex"

matched := regex.match("hello123", /[0-9]+/)    # true
found := regex.find("hello123", /[0-9]+/)     # "123"
all := regex.find_all("a1b2c3", /[0-9]/)     # ["1", "2", "3"]
replaced := regex.replace("hello", /l/, "r")  # "herro"

Directory Management

Use the cd keyword to change directories and pwd() to get the current directory.

Change directory with a block

The directory is restored when the block exits, even on error.

cd "/tmp" {
    print(pwd())          # /tmp
    files := `ls`
}
# back to original directory

Change directory permanently

cd "/var/log"
print(pwd())              # /var/log

Built-in Functions

These functions are available globally without any import.

print

Write values to stderr. Accepts any number of arguments.

print("hello", name, 42)

len

Return the length of a string (Unicode-aware) or array.

len("hello")       # 5
len("héllo")       # 5 (counts runes, not bytes)
len([1, 2, 3])     # 3

split

Split a string by a separator.

split("a,b,c", ",")    # ["a", "b", "c"]

join

Join an array with a separator. Elements are converted to strings.

join(["x", "y"], "-")  # "x-y"
join([1, 2, 3], ", ")  # "1, 2, 3"

trim

Remove leading and trailing whitespace.

trim("  hello  ")     # "hello"

replace

Replace all occurrences of a substring.

replace("hello", "l", "r")  # "herro"

contains

Check if a string contains a substring, or if an array contains an element.

contains("hello", "ell")       # true
contains([1, 2, 3], 2)          # true
contains(["a", "b"], "c")     # false

starts_with

Check if a string starts with a prefix.

starts_with("hello", "he")   # true

ends_with

Check if a string ends with a suffix.

ends_with("hello", "lo")     # true

map

Transform each element of an array using a block. Returns a new array.

map([1, 2, 3]) {|x| x * 2}          # [2, 4, 6]
map(["a", "b"]) {|s| len(s)}      # [1, 1]

Works well with pipes:

[1, 2, 3] |> map {|x| x + 1}      # [2, 3, 4]

to_int

Convert a string to an integer. Returns an error if the string is not a valid number.

n := to_int("42") catch {|err| 0}   # 42

resolve

Wait for an async promise to complete and return its value. See Async.

p := async `sleep 1`
result := resolve(p)

assert / assert_eq

Assertions for testing. They raise an error on failure.

assert(x > 0) catch {|err| print(err)}
assert_eq(x, 42) catch {|err| print(err)}
assert(true, msg: "should be true") catch {|err| print(err)}

pwd

Returns the current working directory. See Directories.

dir := pwd()
FunctionSignatureDescription
print(args... any) voidPrint values to stderr
len(v any) intLength of string (runes) or array
split(s string, sep string) [string]Split string by separator
join(arr [any], sep string) stringJoin array elements with separator
trim(s string) stringRemove leading/trailing whitespace
replace(s string, old string, new string) stringReplace all occurrences
contains(collection any, element any) boolCheck if string contains substring or array contains element
starts_with(s string, prefix string) boolCheck if string starts with prefix
ends_with(s string, suffix string) boolCheck if string ends with suffix
to_int(s string) string! intParse string as integer
resolve(promise T?) TWait for async promise
assert(cond bool, msg: string?) string! voidAssert condition is true
assert_eq(a any, b any, msg: string?) string! voidAssert two values are equal
map(arr [T], block) [U]Transform array elements
pwd() stringCurrent working directory

Imports

Basic import

import "utils/helpers"

result := helpers.do_thing()

Import with alias

import "utils/helpers" as h

result := h.do_thing()

Standard Library

Pitcrew includes a standard library of .pit modules embedded in the binary. Import them by name — no file paths needed.

fs

Filesystem operations.

import "fs"

files := fs.list(".")         # list files in directory
f := files[0]
print(f.name, f.size, f.is_dir)

Each element returned by list is a FileInfo struct with the following fields:

FieldTypeDescription
namestringFile name
sizeintFile size in bytes
is_dirboolWhether the entry is a directory
mtimeintLast modification time (Unix seconds)
atimeintLast access time (Unix seconds)
ctimeintStatus change time (Unix seconds)
FunctionSignatureDescription
list(path string) [FileInfo]List files in a directory with metadata
read(path string) string! stringRead file contents
write(path string, content string) string! voidWrite content to file
stat(path string) string! FileInfoGet file metadata
exists(path string) boolCheck if file exists
is_dir(path string) boolCheck if path is a directory
copy(src string, dst string) string! voidCopy file
move(src string, dst string) string! voidMove file
chmod(path string, mode int) string! voidChange file permissions
chown(path string, uid int, gid int) string! voidChange file ownership

json

JSON parsing and serialization.

import "json"

data := json.parse('{"name": "Alice", "age": 30}')
print(data.name)                  # Alice

text := json.emit(data)          # back to JSON string
keys := json.keys(data)          # ["name", "age"]
t := json.typeof(data.name)     # "string"

regex

Regular expression matching and replacement. See the Regex section.

ps

Process management.

import "ps"

pid := ps.start("nginx") catch {|err| print(err); 0}
info := ps.status(pid) catch {|err| print(err)}
ps.stop(pid) catch {|err| print(err)}
ps.signal(pid, "HUP") catch {|err| print(err)}

net

HTTP requests and network diagnostics.

import "net"

response := net.get("https://api.example.com/health")
open := net.port_open("localhost", 8080)
ips := net.dns_lookup("example.com")

env

Environment variable management.

import "env"

home := env.get("HOME")
env.set("MY_VAR", "value")
all := env.list()

pkg

Cross-platform package management (detects apt, dnf/yum, brew).

import "pkg"

pkg.install("nginx")
installed := pkg.installed("nginx")    # true/false
print(pkg.manager())                   # "apt", "dnf", or "brew"

svc

Service management (systemd, launchd).

import "svc"

svc.start("nginx")
svc.enable("nginx")                  # start on boot
running := svc.running("nginx")     # true/false

tpl

Simple string templating with {{key}} placeholders.

import "tpl"

config := tpl.render("Hello {{name}}, port {{port}}", {"name": "Alice", "port": "8080"})

user

User and group management.

import "user"

user.create("deploy")
user.add_to_group("deploy", "sudo")
exists := user.exists("deploy")

sync

Concurrency control. See Async.

import "sync"
limit := sync.limiter(5)

Cloud providers

Wrappers around cloud CLI tools: aws, gcp, azure.

import "aws"

print(aws.whoami())
files := aws.s3_list("s3://my-bucket")
secret := aws.ssm_get("/prod/db-password")

Run pitcrew doc to generate searchable API documentation for all stdlib modules from their embedded docstrings.

Comments

Line comments start with #.

# This is a comment
x := 42  # inline comment