lowercase
About this project
lowercase is a lightweight C-like programming language that I am currently working on. I got the idea for my own language while learning about compilers in university, where we had to write a simple C compiler.
I always believed that compilers were impossibly difficult for one single person to ever write on their own, but once you learn the basic principles, it actually starts to seem surprisingly doable.
The language is not meant for any large scale projects or as a replacement or enhancement that already exists, it's really just a fun project for myself. The use cases would mostly be small, single file algorithms and programs which benefit from the simplicity of the type creation, as well as the syntactic sugar that is built in.
I wont go into to much detail here as the technical specification of the language is down below in PDF form, but in short, the syntax is primarily C-like, while also borrowing quick type creation similar to OCaml, and a couple modern day features like 'auto' style loops and dynamic lists.
There are also some rather unique types, like super, hyper, and ultra, which are 128, 256, and 512 bit integers respectively. I thought it would be interesting to have native support for these large numbers which are often used in science and cryptography.
Below you'll find some code examples that show off what I hope the language will be capable of, further down are some examples of compiled programs.
// We can set the name of the return variable beforehand, saving us a return statement and making it clear which variable is supposed to be the result.
function square(i64 x[0, inf]) => (u512 n) {
n = x * x;
}
Global variables must be readonly and capslock to insure that the programmer is aware of their trickery. Yes, it won't compile if the name isn't all caps :)
Fast type creation is a large part of the language. To create a new type, just use the type keyword followed by angle brackets containing the fields with their types. This is the 'simple' notation:
type Tree = <Tree left, Tree right, KeyCode value>;
// Usage of a user type
Tree tree = (null, null, (8, true));
We can also use the 'complex' notation to create a type. This allows for functions and operator overloads to be defined
this = <f64 real, f64 imag>;
operator *(Complex a, Complex b) => Complex result {
result = (a.real * b.real) - (a.imag * b.imag);
}
}
Subroutines are functions that don't take any arguments or return anything. The infinitely keyword is basically a while(true) loop, so it has to be broken out of at some point. iterate is my version of a foreach loop.
infinitely {
if (getKey() != null) {
break;
}
}
iterate (KeyCode code : getKeycodes()) {
if (code.isValid) {
print("Keycode: %d", code.key);
}
}
}
Compiled Programs
These are a few examples of programs that the compiler is capable of at the moment. lowercase compiles to x86-64 assembly, which then needs to be linked and compiled again by an assembly compiler such as NASM. I would have liked to create the .exe myself, but that's just one step above what I can do at the moment.
Program 1: Simple print
print("Hello, World!");
}
This compiles to:
__lc__heap_ptr resq 1
section .data
nl db 0x0a, 0x00
__string_0 db 'Hello, World!', 0x00
section .text
global _start
_start:
call main
jmp _exit
__lc__strlen:
mov rax, 0
__lc__strlen_loop:
cmp byte [rsi + rax], 0
je __lc__strlen_done
inc rax
jmp __lc__strlen_loop
__lc__strlen_done:
ret
__lc__print:
push rdi
push rdx
call __lc__strlen
mov rdx, rax
mov rax, 1
mov rdi, 1
syscall
pop rdx
pop rdi
ret
__lc__println:
call __lc__print
lea rsi, nl
call __lc__print
ret
main:
push rbp
mov rbp, rsp
lea rbx, __string_0
mov rsi, rbx
call __lc__println
main_end:
mov rsp, rbp
pop rbp
ret
_exit:
mov rax, 60
xor rdi, rdi
syscall
So, let's try to run it. NASM doesn't really work super well on Windows, so I use WSL2 with Arch to compile it.
You'll notice a bunch of functions with the prefix __lc__. These are actually hand-written assembly functions that I use to perform certain low-level tasks such as printing to the console and converting numbers to strings. How these are implemented is explained below in the technical details section.
Program 2: Factorial
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}
subroutine main {
factorial(5);
}
This compiles to:
__lc__heap_ptr resq 1
section .data
nl db 0x0a, 0x00
section .text
global _start
_start:
call main
jmp _exit
factorial:
push rbp
mov rbp, rsp
sub rsp, 4
mov dword [rbp - 4], edi
mov ebx, dword [rbp - 4]
mov rcx, 0
cmp rbx, rcx
sete dl
cmp dl, 0
je L0_if_else
mov rax, 1
jmp factorial_end
jmp L1_if_end
L0_if_else:
mov ebx, dword [rbp - 4]
mov ecx, dword [rbp - 4]
mov rdx, 1
sub rcx, rdx
mov rdi, rcx
push rbx
call factorial
pop rbx
mov rcx, rax
imul rbx, rcx
mov rax, rbx
jmp factorial_end
L1_if_end:
factorial_end:
mov rsp, rbp
pop rbp
ret
main:
push rbp
mov rbp, rsp
mov rdi, 5
call factorial
mov rbx, rax
main_end:
mov rsp, rbp
pop rbp
ret
_exit:
mov rax, 60
xor rdi, rdi
syscall
Now, unfortunately lowercase does not yet have the modern day technology of printing integers. Therefore, we're gonna have to check that this program works in a more archaic way. We'll debug the program in gdb and check the value of the register rax at the end of the main function.
First, we compile the program:
ld -o factorial factorial.o
Then we run it in gdb with a breakpoint at the main_end:
(gdb) break main_end
(gdb) run
(gdb) print $rax
And here's the result!
Technical details
Now, I could go into detail about how to write a compiler from scratch here, but there are already many resources that explain it way better, and this page is already pretty long. But in short, a compiler has a lexer, a parser and an emitter.
The lexer is responsible for turning the source code into tokens. So for example, in lowercase, the expression '1+1' would be turned into the tokens D_NUMBER[1], PLUS[ + ], D_NUMBER[1].
The parser then takes this list of tokens and attempts to put them into a structure known as an Abstract Syntax Tree (AST). This is a data structure that represents the source code in a recursive way, so that we can traverse it to generate our code.
Finally, the emitter steps through the AST to generate the code for each of the nodes. Below is an example of a full compilation process for a simple program:
Lastly, I'd like to talk about the assembly functions that I mentioned earlier. To be honest, I'm not sure how most languages today solve this kind of problem, but for me it just made sense to have a library of hand-written assembly functions that the compiler accesses when it needs to perform some stereotypical low-level task, such as printing.
I created a class called LibraryFunction that stores the properties of the function:
public class LibraryFunction : Attribute
{
public string LcName { get; } // The assembly name, such as '__lc__strlen'
public string[]? RequiredFunctions { get; } // The names of other functions that this function requires
public string[]? ArgumentRegisters { get; } // The names of the registers that this function expects the arguments to be in
public string[]? Parameters { get; } // What type of parameters this function expects
public string? ReturnType { get; } // The return type
public bool IsReference { get; } // If the returned value is an address
public bool IsInternalOnly { get; } // If this function is only available to the compiler (such as error & abort functions)
public LibraryFunction (string LcName, ...
We can then apply this attribute to a method in the compiler:
public static void print(CodeEmiiter emitter) {
emitter.EmitLabel(new Label("__lc__print"
emitter.PushRegisters(Register.rdi, Register.rdx);
emitter.EmitInstruction("call", "__lc__strlen");
emitter.EmitInstruction("mov", "rdx", "rdi");
emitter.EmitInstruction("mov", "rax", "1");
emitter.EmitInstruction("mov", "rdi", "1");
emitter.EmitInstruction("syscall");
emitter.PopRegisters(Register.rdx, Register.rdi);
emitter.EmitInstruction("ret");
}
Technical Specification
Below you'll find the PDF of the technical specification of the language, which goes into even further detail of the language.