The lippy guide
lippy is a small language for the jobs you'd normally reach for bash or a bit of Python to do — checking services, moving files, poking an API, keeping a little database. It tries to read like plain English and to start instantly.
This is the tour. For the exact rules see Syntax, Types and Errors; for whole working programs see the examples.
Hello
say "hello, world"
Run a one-liner without a file:
lippy eval 'say "hello, world"'
Or put it in a file hello.lip and run lippy hello.lip. Add a shebang and it's a
command of its own:
#!/usr/bin/env lippy
say "hello, world"
chmod +x hello.lip
./hello.lip
Values
lippy has one number type, strings, true/false, nothing, lists, and maps.
count is 42
pi is 3.14
name is "ada"
ready is true
missing is nothing
fruit is ["apple", "pear", "plum"] # a list
ports is {"gitea": 3000, "jellyfin": 8096} # a map (keeps its order)
is is how you assign — the only way. It never means "equals"; comparisons are their
own words (below), so the classic = vs == beginner trap simply doesn't exist.
Strings
Double-quoted strings interpolate — put any expression in { }:
who is "world"
say "hello, {who}!"
say "2 + 2 is {2 + 2}"
When text is full of backslashes, braces or quotes — patterns, JSON — use a raw string in backticks. No escapes, no interpolation, and it may span lines:
pattern is `^IMG_\d+`
blob is `{"port": 3000}`
Comparisons and logic
Conditions read as English. Calculations keep the maths symbols from school.
if age at least 18 and name equals "ada" then
say "hi ada"
end
The words: equals, not equals, greater than, less than, at least, at most,
contains, starts with, ends with, matches (pattern), plus and, or, not.
The maths: + - * / and mod. No == && || > ! to remember.
If, else
if score greater than 90 then
say "top marks"
else if score greater than 50 then
say "a pass"
else
say "try again"
end
then closes the condition and end closes the block. There's no elif to learn — it's
else if.
Loops
for n in 1 to 5
say "counting {n}"
end
for fruit in ["apple", "pear"]
say fruit
end
for name, port in ports # two names walks a map
say "{name} is on {port}"
end
loop runs forever until you stop; skip jumps to the next turn. Both take a when:
loop
guess is number(ask "your guess? ")
stop when guess equals answer
say "nope"
end
A loop can also build a value with give:
names is for s in services give name of s
big is for f in files give f when len(f) greater than 100
Functions
Define with fun, call without parentheses — the arguments run to the end of the line:
fun greet(who)
if not who then
who is "stranger"
end
say "hello, {who}!"
end
greet "ada"
greet name
One rule to know: paren-free calls don't nest, so parenthesise an inner call —
greet upper(name), not greet upper name. lippy's error tells you exactly this if you
forget.
Some functions read as sentences because their last argument uses a preposition —
move a to b, write text to path, push item to list. That's ordinary syntax; your own
functions can do it too:
fun push(item, to list) # called: push item to keep
Reading data out: at and of
There's no dot notation and no [ ]. Two words do it:
first_fruit is fruit at 1 # lists are 1-based
gitea_port is ports at "gitea" # maps use the same word
count is len of fruit # a property
line1 is first of lines of read "notes.txt"
at key indexes a list or map (and assigns: scores at "ada" is 10). name of thing
reads a property.
Talking to the shell
This is what lippy is for. run executes a command (and stops the script if it fails);
capture runs one and hands back its output. Wrap either in attempt when failure is
allowed and you want to handle it yourself:
run "rsync -a {src}/ {dst}/" # dies if rsync fails
state is capture "systemctl is-active {name}"
result is attempt capture("systemctl is-active {name}")
if ok of result then
say "{name} is up"
else
say "{name} is down — exit {code of result}"
end
Values handed to a command can never split into extra arguments — a filename with spaces stays one argument, so command injection isn't a footgun you have to remember.
Files
text is read "notes.txt"
write "all done\n" to "status.txt"
push "another line" to log # append to a list, then write it
move "old.txt" to "new.txt"
Batteries
Everything below is built in — no packages to install.
JSON / YAML / CSV. Parse and produce, keeping map order:
config is from_json(read "config.json")
say to_yaml(config)
rows is from_csv(read "data.csv") # a list of maps, header as keys
The web. fetch gets a URL's body (and attempt fetch(…) to survive a failure);
serve runs a tiny HTTP server from a table of functions:
body is fetch("https://example.com")
fun home(request)
return "hello from lippy"
end
serve {"/": home}, 8080
An answer's type picks the content type — return a string and it's text, return a map and it's JSON.
Databases. SQLite needs no server; MySQL/MariaDB is the same three verbs. The ?
placeholder means a value can never become SQL:
db is connect("sqlite", "notes.db")
execute(db, "insert into notes (text) values (?)", note)
rows is query(db, "select * from notes where done = ?", 0)
close(db)
Markdown. Render it, or pull it apart:
write to_html(read "post.md") to "post.html"
for h in headings(read "post.md")
say "{level of h}: {text of h}"
end
Dates. Plain strings that sort and compare correctly, because the format is chosen so text order is time order:
say "backup-{today()}.tar.gz"
if today() at least days_ago(7) then say "recent" end
Errors
lippy treats error messages as a feature. They say what went wrong, point at where, and usually suggest the fix:
lippy stopped at line 3 of greet.lip
line 3: say len args + 1
^^^^
can't add 1 to a list — did you mean len(args) + 1?
More on how errors are designed in Errors.