Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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
\aalert
\bnon-destructive backspace
\vvertical tab
\fform feed
\nnew line
\rcarriage return
\thorizontal tab
\'single quote
\"double quote
\\backslash
\eescape
\xhex code
\uunicode byte sequence

\x and \u can be used to directly embed byte sequences in strings:

  • \x0A is 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.