Language guide
Hebi’s syntax is inspired by, and borrows from:
- Rust
- JavaScript
- Lua
- Python
- Gleam
It’s in the same family of bracketed programming languages as Rust (the BCPL family) with some ML influences, such as implicit returns.
Note: Hebi’s implementation is still heavily in flux, and syntax may still change at any point!
// disclaimer: this module doesn't actually exist :)
import {service, response, header, body} from "std/http/server"
fn fib(n) {
if n < 2 { n }
else { fib(n-1) + fib(n-2) }
}
service({
"/fib/:num": fn(req) {
let num = parse_int(req.params.num)
if num > 40 {
return response(400)
}
let result = fib(num)
response(200)
|> header("Content-Type", "text/plain")
|> body(to_str(result))
},
})
Comments
It’s often useful and important to annotate your source code with some extra information for yourself and other people. The only form of a comment you can write in Hebi is:
#![allow(unused)]
fn main() {
// A line comment.
}
There are no block comments or special doc comments.
Statements
The base unit of syntax in Hebi is a statement. Every program consists of statements. Each statement does something, and produces no value.
Variables
#![allow(unused)]
fn main() {
let v = nil
}
Variables must have both a name and a value. let without value is a syntax error:
$ hebi4 -e 'let v'
expected '=', found '<eof>'
1 | let v
| ^
All variables in Hebi are mutable, meaning you can change their value by assigning to them:
let v = nil
print(v) // prints "nil"
v = 10
print(v) // prints "10"
There are two kinds of variables:
- Module variables (module-scoped globals)
- Local variables (block-scoped)
Variables scoped to a module can be accessed by other modules. They are also shared by all functions in the same module.
Block-scoped variables are only visible to other statements in the same scope. They can also be captured by functions, which is discussed in the next section.
Functions
#![allow(unused)]
fn main() {
fn f() {
}
fn f(a, b, c) {}
}
Functions may also be nested.
#![allow(unused)]
fn main() {
fn outer() {
fn inner() {}
}
}
All functions are also closures. Functions in the module scope close over the entire module, while functions in inner blocks close over only local variables and functions visible to them.
#![allow(unused)]
fn main() {
fn f() {
print(v)
}
let v = "hi"
}
You can of course call functions, if they’re in scope:
#![allow(unused)]
fn main() {
f() // prints "hi"
}
#![allow(unused)]
fn main() {
fn is_even(n) {
if n == 1 { false }
else { is_odd(n-1) }
}
fn is_odd(n) {
if n == 1 { true }
else { is_even(n-1) }
}
print(is_even(10)) // prints "true"
}
When a local variable is captured, its value is copied into the function’s closure environment. This is different from how closures work in JavaScript, Lua, and Python.
#![allow(unused)]
fn main() {
fn counter() {
let v = 0
return {
get: fn() { v }
set: fn(n) { v = n }
}
}
let c = counter()
print(c.get()) // 0
c.set(10)
print(c.get()) // still 0
}
If you want a shared mutable closure environment, you can wrap your data in a table:
#![allow(unused)]
fn main() {
fn counter() {
let s = { v: 0 }
return {
get: fn() { s.v }
set: fn(n) { s.v = n }
}
}
let c = counter()
print(c.get()) // 0
c.set(10)
print(c.get()) // 10
}
Imports
Modules may import other modules.
import {a, b, c} from "foo"
They are evaluated dynamically, wherever the import appears. To import something lazily, you can wrap it in a function:
fn f() {
import {a, b, c} from "foo"
foo()
}
You can either import parts of a module, or the entire module:
import {a,b,c} from "foo" // import only parts of the module
import {a,b,c} from foo // same thing, but the module specifier is an identifier
import foo from "foo" // import entire module
import foo from foo // same as above
import foo // shorthand for `foo from foo`
Expressions
Statements operate on values, which are produced by expressions.
Literals
Literals (also known as constants) are values which are taken literally from the source code.
Nil
The “nothing” value:
v = nil
Booleans
A binary true or false value.
v = true
v = false
Used in control flow.
Numbers
There are two kinds of numbers:
- Floating point numbers, and
- Integers
They are distinct types, but are generally interchangeable:
let int = 2
let float = 3.14
print(int * float)
If you want to convert between them explicitly, you can use the built-in to_int and to_float.
Calling to_int on a floating point has the same effect as rounding down the float.
Strings
A string is a sequence of characters. Strings in Hebi use UTF-8 encoding. They are also immutable.
let v = "hi!"
Strings also support escape sequences, which have special meaning:
| meaning | |
|---|---|
\a | alert |
\b | non-destructive backspace |
\v | vertical tab |
\f | form feed |
\n | new line |
\r | carriage return |
\t | horizontal tab |
\' | single quote |
\" | double quote |
\\ | backslash |
\e | escape |
\x | hex code |
\u | unicode byte sequence |
\x and \u can be used to directly embed byte sequences in strings:
\x0Ais equivalent to\n\u{1F602}is an emoji (😂)
Note that they must still result in valid utf-8.
Many of these escapes are pretty archaic and aren’t really useful anymore.
An example of that is \a (alert), which was used to literally produce some kind of sound when
the computer detected it in standard output.
Use these wisely!
Containers
Hebi has two built-in container types: arrays and tables.
Array
An array is a dynamically-sized sequence of values.
let v = [0,1,2]
You can access the values in an array using an index:
v[0] + v[1]
Only integers are valid indices.
Tables
A table is a sequence of key-value pairs. They are sometimes called hash maps or associative arrays.
let v = {a:0, b:1, c:2}
Tables are indexed by strings.
print(v["a"]) // 0
Other types must be explicitly converted to strings before being used as keys:
let k = to_str(0)
v[k] = "hi"!
print(v[k])
You can also use the field syntax to index tables:
print(v.a) // 0
Blocks
let v = do { 1 + 1 }
You can use these to limit the scope of things. The final expression in the block is its value. This is known as an implicit return.
Implicit returns
In Hebi, the final expression in any block of code is its return value, just like in Rust.
#![allow(unused)]
fn main() {
fn foo() {
"hi"
}
print(foo()) // prints "hi"
}
#![allow(unused)]
fn main() {
print(do { "hi" }) // prints "hi"
}
Control flow
Hebi is a Turing-complete language, offering loops and conditional expressions:
#![allow(unused)]
fn main() {
let i = 0
loop {
print(i)
if i == 10 {
print("end")
break
}
i += 1
}
}
If expressions
#![allow(unused)]
fn main() {
fn fizzbuzz(n) {
if n % 3 == 0 and n % 5 == 0 {
"fizzbuzz"
} else if n % 3 == 0 {
"fizz"
} else if n % 5 == 0 {
"buzz"
}
}
let i = 0
loop {
if i > 100 { break }
printf("{i} {v}", {i, v:fizzbuzz(i)})
i += 1
}
}
if expressions also support implicit returns:
#![allow(unused)]
fn main() {
fn test(number) {
if number > 10 {
"That's more than I can count."
} else {
to_str(number)
}
}
print(test(1)) // prints "1"
print(test(100)) // prints "That's more than I can count."
}
Loops
Currently, the only loop kind is an infinite loop:
#![allow(unused)]
fn main() {
loop {
// ...
}
}
You can break out of it, or continue to go back to the start of the loop.
That’s it!
As you can tell, some stuff is missing. Hebi is quite minimal right now, and while it will stay minimal, it shouldn’t stay this minimal.