benzene_lang
An experiment in language design, compilers, and C++ metaprogramming.
Benzene started after I bought Dick Grune's Modern Compiler Design and The BEAM Book, while also experimenting with C++ metaprogramming. I naturally like to get to the bottom of things, and apparently that includes trying to build a programming language. As for why I called it benzene_lang, well, you take a guess lol.
It's a small, statically typed, declarative language with a C++ compiler front-end called ether. The lexer, parser, resolver, and diagnostics work. Type checking and code generation are still stubs, so there is quite a bit left to figure out. For now, it's a place to put what I'm learning into practice.
The file I keep pointing the front-end at is tests/frontend/demo.bz. Half of it is a program; the other half is deliberate misuse, so the resolver has something to complain about. Here's the half that's meant to hold up.
Modules load at the top, and const lives at module scope. Constants take literals only — no expressions on the right-hand side:
Load benzene.list
Load benzene.threading
const x: Int = 10
const y: Float = 3.5
const name: String = "Dr. Lex"
const valid: Bool = True
Functions are func, a name, optional parameters, an optional :> return type, and end. let binds inside the body, and the last expression is what the function gives back:
func compute(bar: String, price: Float) :> Float
let scaled = price * 2
let label: String = bar
scaled
end
Braces aren't grouping — a scoped expression is a block of expressions whose value is the last one, so it can sit on the right of a binding and nest inside itself:
func nested()
let foo = 10 * 12 + 3 - 1 / 2
let bar: Int = { 10 - 12 / 3 * 4 - {
let a = foo * 3 - 12
a || {2 > 3 || 5 ~= 2 && 4 >= 2}
}}
bar
end
~ is the unary negation, and ~= is "not equal" — I wanted ! to stay free. Precedence runs the usual way, || loosest and unary tightest, left-associative at every level.
case matches an expression against arms joined by the same :> arrow, and |=> threads a value through a chain of calls:
func classify(reading: Float)
case reading > 3.5:
True :> {
to_float(reading)
|=> wrap_with_logs("logs")
|=> to_result()
}
False :> println("below threshold", reading)
end
end
Comments are Cmt, either to the end of the line or wrapped in braces:
Cmt single line comment
Cmt {
Let bindings are only allowed in function scopes. One written at module
scope still parses; the resolver is what stops it.
}
That comment describes the rest of demo.bz. The parser accepts more shapes than the language allows, so scope is the resolver's job — it walks the tree, marks anything in the wrong place is_poisoned, and emits a diagnostic instead of quietly moving on:
let stray = "dada" Cmt a let outside any function
func const_in_function() :> Nothing
const x = 10 Cmt a const inside one
end
{ Cmt a block with no function around it
let x = 10
}
A few tokens exist with nothing behind them yet. %, [, ], and default all lex, and demo.bz uses lists and an @{...} tuple that the parser doesn't know how to read. They're in the lexer so adding the syntax later doesn't mean changing how the file tokenizes.
To see what the front-end makes of any of this, the README has:
./bin/ether check tests/frontend/demo.bz -show-ast
That checks the source and prints its syntax tree, poisoned nodes included. It doesn't execute the program yet. The grammar tracks whatever the parser actually does, which is the only version worth writing down.