lippy.

Examples

Thirteen complete programs, from a one-line hello to a status page lippy serves itself. They're real scripts you can read top to bottom and run — each one shows off a different part of the language. The full source of every one is below the table.

# Program What it shows
01 hello say
02 fizzbuzz for n in 1 to 100, if/else if/else, mod, equals
03 greet fun, paren-free calls, ask, truthiness, interpolation
04 rename-photos first of args, files of, starts with / ends with, move … to
05 findin args at n, lines of read …, contains, exit
06 backup run (stops on failure) and capture (returns output)
07 disk-alert reading a command's output, at, skip when, greater than
08 todo subcommand arguments, appending to a list, write … to, not equals
09 guess loop / stop when, less than, random 1 to 100
10 service-check maps, for key, value in map, attempt, ok of result
11 status-page serve with a route table of functions, :name path parameters, attempt fetch
12 tally options for the command line, for line in stdin (a filter), warn to stderr
13 notes connect / query / execute — a SQLite-backed CLI, and the ? placeholder

The scripts

01-hello.lip

#!/usr/bin/env lippy
# The smallest lippy program.

say "hello, world!"

02-fizzbuzz.lip

#!/usr/bin/env lippy
# The classic. Shows: the counting loop, if/else if/else, `mod`, word comparisons.

for n in 1 to 100
    if n mod 15 equals 0 then
        say "fizzbuzz"
    else if n mod 3 equals 0 then
        say "fizz"
    else if n mod 5 equals 0 then
        say "buzz"
    else
        say n
    end
end

03-greet.lip

#!/usr/bin/env lippy
# Functions, `ask` for input, string interpolation, truthiness of empty string.
# Calls are Ruby-style: no parens needed — `greet name`, not `greet(name)`.

fun greet(who)
    if not who then
        who is "stranger"
    end
    say "hello, {who}!"
end

name is ask "what's your name? "
greet name

04-rename-photos.lip

#!/usr/bin/env lippy
# Rename IMG_1234.JPG style files to holiday-1234.jpg.
# Shows: `first of args`, string verbs, `starts with`/`ends with`, moving files.

folder is first of args
if not folder then
    say "usage: rename-photos <folder>"
    exit 1
end

count is 0
for name in files of folder
    if name starts with "IMG_" and lower(name) ends with ".jpg" then
        digits is trim(name, "IMG_", ".JPG", ".jpg")
        move "{folder}/{name}" to "{folder}/holiday-{digits}.jpg"
        count is count + 1
    end
end

say "renamed {count} photos"

05-findin.lip

#!/usr/bin/env lippy
# A tiny grep: findin <word> <file>
# Shows: `args at n` indexing, reading a file line by line, `contains`.

word is args at 1
path is args at 2
if not word or not path then
    say "usage: findin <word> <file>"
    exit 1
end

lineno is 0
for line in lines of read path
    lineno is lineno + 1
    if line contains word then
        say "{path}:{lineno}: {line}"
    end
end

06-backup.lip

#!/usr/bin/env lippy
# Back up Projects to the NAS. Shows: `run` (dies loudly on failure),
# `capture` (returns trimmed stdout), interpolation into commands.

source is "/home/lee/Projects"
dest is "crate.local:/volume1/backups"

today is capture "date +%F"
say "backing up {source} -> {dest} ({today})"

run "rsync -a --delete {source}/ {dest}/projects-{today}/"

size is capture "du -sh {source}"
say "done. backed up {size}"

07-disk-alert.lip

#!/usr/bin/env lippy
# Warn when any disk is over 90% full.
# Shows: parsing command output, `at` indexing, `skip when`, word comparisons.

for line in lines of capture "df --output=pcent,target"
    parts is split trim(line)
    skip when parts at 1 equals "Use%"

    used is number trim(parts at 1, "%")
    mount is parts at 2

    if used greater than 90 then
        say "WARNING: {mount} is {used}% full"
    end
end

08-todo.lip

#!/usr/bin/env lippy
# A tiny todo list kept in a text file: todo add "buy milk" / todo list / todo done 2
# Shows: subcommand-style args, read/write files, lists, `not equals`.

path is "{home}/.todo.txt"
command is first of args

if command equals "add" then
    append "{args at 2}\n" to path
    say "added"

else if command equals "list" then
    n is 0
    for item in lines of read path
        n is n + 1
        say "{n}. {item}"
    end

else if command equals "done" then
    keep is []
    n is 0
    for item in lines of read path
        n is n + 1
        if n not equals number(args at 2) then
            push item to keep
        end
    end
    write join(keep, "\n") to path
    say "done"

else
    say "usage: todo add <text> | todo list | todo done <n>"
    exit 1
end

09-guess.lip

#!/usr/bin/env lippy
# Number guessing game.
# Shows: the condition-less loop with `stop when`, random numbers, word
# comparisons, and `is_number` — the gentle way to check before converting.

secret is random 1 to 100
tries is 0

say "I'm thinking of a number between 1 and 100"

loop
    answer is ask "your guess? "

    # number() stops the script on rubbish, which is right for a script
    # reading a config file and wrong for a game. is_number asks instead.
    if not is_number(answer) then
        say "that's not a number — have another go"
        skip
    end

    guess is number(answer)
    tries is tries + 1

    stop when guess equals secret

    if guess less than secret then
        say "higher!"
    else
        say "lower!"
    end
end

say "got it in {tries} tries!"

10-service-check.lip

#!/usr/bin/env lippy
# Check homelab services and report. Shows: maps, `attempt` (a run that's
# allowed to fail), and `of` for reading properties — no dot notation.

services is {
    "gitea": "the git server",
    "jellyfin": "movies and tv",
    "navidrome": "music",
}

problems is 0
for name, what in services
    result is attempt capture("systemctl is-active --quiet {name}")
    if ok of result then
        say "  ok    {name} ({what})"
    else
        say "  DOWN  {name} ({what}) — exit code {code of result}"
        problems is problems + 1
    end
end

if problems greater than 0 then
    say "{problems} services need attention"
    exit 1
end
say "all good"

11-status-page.lip

#!/usr/bin/env lippy
# A status page for the homelab, served by lippy itself. Shows: functions
# as values (a route table is just a map of them), `:name` path parameters,
# and answers whose type picks the content type — a string goes back as
# text, a map as JSON.
#
# Run it, then visit http://localhost:8080

boxes is {
    "crate": "http://crate.local",
    "create": "http://create.local",
}

# Ask every box how it's doing. `attempt` never stops the script, so one box
# being down doesn't take the status page with it.
fun check_all()
    results is {}
    for name, address in boxes
        answer is attempt fetch("{address}/health")
        results at name is ok of answer
    end
    return results
end

fun home(request)
    lines is ["homelab status", ""]
    for name, healthy in check_all()
        mark is "DOWN"
        if healthy then
            mark is "up"
        end
        push "{name}: {mark}" to lines
    end
    push "" to lines
    push "checked at {now()}" to lines
    return join(lines, "\n")
end

# A map comes back as JSON, so this is an API with no extra work.
fun health(request)
    return {"boxes": check_all(), "at": now()}
end

# :name is a path parameter — /box/crate arrives as params of request.
# `reply` is for when the code matters; a plain return is a 200.
fun box(request)
    name is params of request at "name"
    if not (boxes contains name) then
        return reply "there's no box called {name}", 404
    end
    answer is attempt fetch("{boxes at name}/health")
    return {"box": name, "ok": ok of answer, "why": why of answer}
end

serve {
    "/": home,
    "/health": health,
    "/box/:name": box,
}, 8080

12-tally.lip

#!/usr/bin/env lippy
# A filter: reads lines from a pipe, writes lines to a pipe.
# Shows: `options` for the command line, `for line in stdin` (streamed,
# never slurped), and `warn` for diagnostics — once stdout is data, notes
# have to go somewhere else.
#
#   journalctl -u gitea --no-pager | ./12-tally.lip ERROR --limit=5
#
# Counts matching lines and prints them; the tally goes to stderr, so it
# doesn't pollute the output for whatever comes next in the pipeline.

# Each option's default says what it is: false is a flag, nothing takes a
# value, a number takes a number. Everything that isn't an option lands in
# `rest of opts`.
opts is options {
    "limit": 0,          # stop after this many matches (0 means no limit)
    "quiet": false,      # skip the tally
}

wanted is first of (rest of opts)
if not wanted then
    warn "usage: … | tally.lip <text-to-match> [--limit=n] [--quiet]"
    exit 1
end

seen is 0
matched is 0

for line in stdin
    seen is seen + 1
    skip when not (line contains wanted)

    matched is matched + 1
    say line
    stop when limit of opts greater than 0 and matched at least limit of opts
end

if not quiet of opts then
    warn "{matched} of {seen} lines contained {wanted}"
end

13-notes.lip

#!/usr/bin/env lippy
# A note keeper backed by SQLite — lippy with its own database, no server.
#
#   notes.lip add "check the backups ran"
#   notes.lip list
#   notes.lip done 3
#
# Shows: connect / execute / query, and the point of the whole thing — the
# `?` placeholder, where a value is a bound parameter and can never become
# SQL. `notes.lip add "'; drop table notes; --"` stores that text and does
# no harm.

db is connect("sqlite", "{home}/.notes.db")
execute(db, "create table if not exists notes (id integer primary key, text text, done number default 0)")

command is first of (rest of options {})
rest is rest of options {}

if command equals "add" then
    text is rest at 2
    if not text then
        warn "usage: notes.lip add \"the note\""
        exit 1
    end
    execute(db, "insert into notes (text) values (?)", text)
    say "added"

else if command equals "list" then
    rows is query(db, "select id, text, done from notes order by id")
    if not rows then
        say "no notes"
    end
    for note in rows
        mark is "[ ]"
        if done of note then
            mark is "[x]"
        end
        say "{mark} {id of note}. {text of note}"
    end

else if command equals "done" then
    id is number(rest at 2)
    r is execute(db, "update notes set done = 1 where id = ?", id)
    if changed of r equals 0 then
        warn "there's no note {id}"
        exit 1
    end
    say "marked {id} done"

else
    warn "usage: notes.lip add|list|done"
    exit 1
end

close(db)