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

Introduction

Henceforth is a stack-based programming language that emphasizes direct stack manipulation, in a more modern way than its predecessors, like Forth or Joy, while also giving users the convenience of imperative programming. Unlike traditional languages with nested function calls and expressions, Henceforth operations work with a stack data structure, giving programmers fine-grained control over the program's execution, while also being user-friendly.

Key Characteristics

Stack-based execution

At the core of Henceforth is the stack. Instead of complex expressions being evaluated and stored into variables, Henceforth has a more step-by-step approach, and variables are nothing more than wrappers for simple values. This makes data processing more explicit, predictable and natural.

Explicit stack manipulation

By integrating imperative programming features into a stack-based language, it might get a little confusing whether or not something affects the stack. Henceforth uses special notation to fix this issue, making it explicit when something is manipulating the stack.

Seamless context switching

Unlike Forth, where everything is in one stack, Henceforth creates a new stack for every function, merging the callee and caller's stacks when the function is done executing, all done in a way that feels natural. Control flow is also handled by the compiler, so that, for example, every branch of an if statement starts with the same stack.

Strong Static Typing

Despite its low-level feel, Henceforth is statically typed with types like i32, f32, string, and bool. The type system ensures stack operations are safe and well-defined.

Quick Feature Overview

Functions with Stack-Based Arguments

fn add: (i32 i32) -> (i32) {
  @(+); 
}

Stack Blocks for Direct Fine-grained Manipulation

@(1 3 + 2 *)  // Push 1, push 3, add, push 2, multiply

Several Ways to Call Functions

@(5 3) :> add; // copies the arguments
@(5 3) &> add; // moves the arguments

Lexical Structure

This section describes the basic lexical elements that make up Henceforth source code.

Comments

Line comments are indicated by //. Block comments are opened with /* and closed with. */, and can be nested.

  // this is a comment

  /*
    this is also a comment
  */

  /*
    /*
        this is a nested comment
    */
    this is still a comment
  */

Identifiers

Function and variable identifiers can contain a-z, A-Z, 0-9 and _, except for the first character, which can't be a number.

add // valid
getValue2 // valid
_private // valid

2var // invalid (starts with number)
my-function // invalid (has hyphen)
hello world // invalid (has space)

Literals

Integer literals represent whole numbers:

42
-17
1000000

Floating-point literals represent decimal numbers:

3.14
-0.5
2.0

String literals are enclosed in double quotes:

"Hello, World!"
""
"Line 1\n Line 2"

The following escape sequences are allowed:

\n newline
\t tab
\\ backslash
\" double quote
\r carriage return

Boolean literals represent truth values:

true
false

Tuple literals group other literals together:

(5 3 "string")
(5.25 "test" true)

Keywords

The following identifiers are reserved as keywords and can't be used as an identifier.

fn
while
continue
break
return
let
if
else
false
true
i32
f32
str
bool
@pop
@pop_all
@dup
@swap
@over
@rot
@rrot
@nip
@tuck

Operators

Henceforth provides several categories of operators.

Arithmetic Operators

+    addition
-    subtraction
*    multiplication
/    division
%    modulo

Comparison Operators

==   equal to
!=   not equal to
<    less than
>    greater than
<=   less than or equal to
>=   greater than or equal to

Logical Operators

&&   logical AND
||   logical OR
!    logical NOT

Special Syntax

@    stack block indicator
()   tuple constructor/stack block delimiter
:    function signature separator
->   return type indicator/argument redirection
{}   block delimiters
;    statement terminator
:=   copy top value to variable
&=   pop to variable
:>   copy arguments to function call   
&>   move arguments to function call

Types

Henceforth's simple type system with static typing separates it from other stack-based languages, allowing for a more user-friendly experience.

Primitive Types

Henceforth has four basic types:

  • i32 - 32-bit signed integer
  • f32 - 32-bit floating-point
  • str - UTF-8 encoded text
  • bool - true or false

For grouping values, there are also tuples, denoted by parentheses around values.

Type Annotations

Henceforth doesn't have type inference, and all types must be specified.

fn foo: (i32 str) -> (bool) { ... }
         ^^^^^^^      ^^^^
         parameters   return type

Type Conversions

There are only three standard library functions to convert types:

fn i32_to_str: (i32) -> str {...}  
fn f32_to_str: (f32) -> str {...}  
fn bool_to_str: (bool) -> str {...}  

Operators implicitly convert between i32 and f32.

Type Compatibility

Being statically typed, Henceforth validates stack operations and function calls before running the program.

Stack Operations

Being a stack-based language, the stack is the core of Henceforth. This data structure has a LIFO policy, where the last element that went in the stack is the first element to come out.

Key differences from other stack-based languages

  • In Henceforth, every function has its own stack, and these stacks can be seamlessly merged.
  • Since not every operation affects the stack, Henceforth explicitly states whether something is changing it.
  • For convenience, the interpreter handles some of the stack manipulation when switching contexts.

Stack blocks

Stack blocks are Henceforth's way of separating imperative from stack-based code. They are delimited by @( and ). They don't have to be terminated by semicolons.

Pushing values to the stack

To push a value to the stack, just write a literal or a variable inside a stack block.

@(5) // pushes 5
@(5.0) // pushes 5.0
@(true) // pushes true
@((1 2 3)) // pushes (1 2 3)
@("string") // pushes "string"
@(var) // pushes the value of var, if var is a variable and has a value

Stack Operators

The following operators are allowed on the stack:

+ // pops two numbers, pushes their sum OR pops two strings, pushes their concatenation
- // pops two numbers, pushes their difference
* // pops two numbers, pushes their product
/ // pops two numbers, pushes their quotient
% // pops two numbers, pushes their integer division remainder
== // pops two numbers, pushes true if they're equal and false otherwise
!= // pops two numbers, pushes true if they're not equal and false otherwise
< // pops two numbers, pushes true if the left value is less than the right value and false otherwise
> // pops two numbers, pushes true if the left value is greater than the right value and false otherwise
<= // pops two numbers, pushes true if the left value is less than or equal to the right value and false otherwise
>= // pops two numbers, pushes true if the left value is greater than or equal to the right value and false otherwise
! // pops a bool, pushes its inverse
|| // pops two bools, pushes true if any of them are true and false otherwise
&& // pops two bools, pushes true if both are true and false otherwise

Stack manipulation builtins

There are several keywords for working with the stack. These are denoted by the @ prefix and work with the stack despite not always being in a stack block.

@pop // removes the argument from the stack
@pop_all // removes everything from the stack
@dup // duplicates its argument
@swap // swaps its arguments
@over // copies second element to top of stack
@rot // moves third element to the top of the stack
@rrot // moves first element to the third position of the stack (equivalent to calling rot twice)
@nip // pops second element from the stack
@tuck // copies first element to the third position of the stack

Common mistakes

Some common mistakes when using the stack include:

@(+) // ERROR: stack underflow

A stack underflow occurs when an operator expects more values than there are on the stack.

@(5 "hello" +) // ERROR: type mismatch

A type mismatch occurs when an operators expects values of a certain type but is given other types.

Variables

Variables are one of the many imperative features that make Henceforth so powerful. Instead of relying entirely on the stack, they allow for keeping state locally.

Variable declaration

Variables can be declared with the following syntax:

let x: i32;
let y: (i32 string);

Variables can't be initialized on declaration, they must use values on the stack.

let x: i32 = 5; // not valid

How variables interact with the stack

Variables can be assigned values directly from the stack:

  • := copies the top value from the stack into the variable
  • &= moves the top value from the stack, popping it, into the variable
let var: i32;
@(1 2 3) := var; // stack: [1, 2, 3], var = 3
let var: i32;
@(1 2 3) &= var; // stack: [1, 2], var = 3

The same rules apply to tuples:

let var: (i32 i32);
@((1 2)) := var; // stack: [(1 2)], var = (1 2)
let var: (i32 i32);
@((1 2)) &= var; // stack: [], var = (1 2)

Variables can be pushed onto the stack like any other literal.

let var: bool;
@(5); // stack: [5]
@(6); // stack: [5 6]
@(>); // stack: [false]
:= var; // stack: [false], var = false
@(var); // stack: [false, false], var = false

Scoping rules

Variables are always local to their scope, and you can't declare variables on a global scope.

let x: i32; // not valid

fn foo: () -> () {
  let x: i32; // valid
  @(x); // valid
  ...
}

fn main: () -> () {
  @(x); // invalid
  ...
  if @(...) {
    let y: i32; // valid
  }
  @(4) := y; // invalid
}

Functions

Unlike other stack-based languages, functions in Henceforth are statically typed, giving a better abstraction than the normal approach of "pop from stack, push to stack".

Declaring functions

Functions can be declared with the following syntax:

fn function_name: (i32) -> (str bool) {
  ...
}
/*
fn <name>: (<argument types>) -> (<return types>) {
  <body>
}
*/

They must be declared on a global scope.

How function calls affect the stack

There are two operators to call a function:

@(1 2 3) :> foo;
@(1 2 3) &> foo;

The :> operator copies the arguments, keeping them in the stack, and the &> operator moves the arguments, removing them from the stack. This syntax closely resembles the := and &= operators, and their behavior is similar.

How context switches between function calls

In Henceforth, functions have separate stacks, and these stacks are merged between function calls. Here's an example of a function that mimics the functionality of the @dup keyword.

fn duplicate: (i32) -> (i32 i32) {
  let temp: i32;
  := temp;
  @(temp);
}

fn main: () -> () {
  @(5) &> duplicate;
  @pop_all;
}

Now we can trace the program's execution:

  • When main (the entry point for any Henceforth program) starts running, the stack is empty.
  • When calling duplicate with the argument 5, the following happens:
    • 5 is pushed into the stack of main, then we call dup by moving its argument with &>.
  • When switching to the context of duplicate, the main stack is now empty, and the popped arguments are pushed into duplicate's stack, which now contains [5].
  • The function then creates a variable, copies the top of the stack (5) into it, and pushes the variable on the stack, essentially duplicating the value. Now the stack of duplicate contains [5, 5]
  • When finishing duplicate's execution, return is not necessary, since the function will just return whatever is in the stack when the execution finishes. The return keyword is only necessary for early returns.
  • When switching context, like before, we put it into the stack of main, so now it contains [5, 5].
  • If we tried returning now, the stack would be compared against the return type of main and it would fail. To fix that we use @pop_all to remove everything from the stack, so we can safely return.

Control Flow

In other stack-based languages, control flow is handled within the stack, leading to some questionable syntax (looking at you, Forth). Henceforth fixes this by handling it imperatively, leading to a more familiar way of doing things.

While loops

while loops are the primary way of repeating things in Henceforth, since for loops don't exist. They are used in the following way:

while @(<condition>) {
  <body>
}

As expected, the body of the loop will be repeated while the condition is still true. The one thing Henceforth does differently from other stack-based languages is that, when looping, it pops the result of evaluating the condition when entering the body, and when leaving the body after the condition is evaluated as false. In Henceforth, a while loop must not change the depth of the stack.

This means that, if something changes the stack inside the loop's body, it must be stored in a variable before the next iteration.

The following keywords can be used inside while loops to change its execution:

  • break - leaves the loop entirely
  • continue - skips the rest of the body's execution, starting the next iteration

If statements

if statements can be used to conditionally perform operations. They are used in the following way:

if @(<condition1>) {
  <if-body>
} else if @(<condition2>) {
  <else-if-body>
} else if ... {
  ...
} else {
  <else-body>
}

This syntax mirrors imperative syntax from languages like C, instead of the confusing <condition> if <body> then ... of Forth.

Like in while statements, whenever a new branch is entered, the stack is restored to its previous state before the loop, and the result of evaluating the condition is always popped automatically. In an if statement, all branches must have the same stack depth.

Example

This all may seem very confusing, let's look at a simple FizzBuzz program.

fn fizz_buzz: (i32) -> (str) {
	if @(15 % 0 ==) {
		@("fizzbuzz");
	} else if @(3 % 0 ==) {
		@("fizz");
	} else if @(5 % 0 ==) {
		@("buzz");
	} else {
		@pop;
		@("no fizzbuzz");
	}
}

Let's trace the program's execution:

  • When entering the if statement the stack contains only the argument.
  • The condition's stack block is executed, leaving true or false in the stack.
  • Assuming the evaluated condition was true, when entering the body, this value is popped, so we add "fizzbuzz" to the stack and return with a stack that matches the expected return type.
  • If the condition was false, we move on to the next branch. The stack is restored to just having the argument, and the condition is evaluated in the same way as before.
  • When reaching the else, the stack only contains the argument, and since there is no condition, it stays there while entering the body, so we have to pop it.
  • We then push the string "no fizzbuzz" and return as expected.

Essentially, control flow boils down to these rules:

  • Every branch of an if statement and every iteration of a while loop, starts with the same exact stack
  • All branches of an if statement must have the same final stack depth
  • A while loop must maintain stack depth
  • When entering the else branch of an if statement, since there's no condition, everything stays there

Standard Library Functions

Henceforth contains many functions in the standard library that allow for more abstraction out-of-the-box.

String Manipulation and IO Functions

Currently, the Henceforth standard library only contains three functions for string manipulation:

fn i32_to_str: (i32) -> str;
fn i32_to_str: (f32) -> str;
fn bool_to_str: (bool) -> str;

This converts an i32, f32 or bool into a string. A function like this goes hand-in-hand with IO, for which Henceforth contains two functions:

fn print: (str) -> (str);
fn print_stack: () -> ();

print_stack differs from other functions as it affects the caller's entire stack, without explicitly passing it, printing it in reverse order (bottom to top), and works with any type. print simply prints its argument.