← All projects
In developmentC++Started Sep 2025

benzene_lang

An experiment in language design, compilers, and C++ metaprogramming.

C++CompilersLanguage design

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.

Cover of Modern Compiler Design, second edition, by Dick Grune and coauthors
Modern Compiler Design, second edition. Cover: Springer.

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.