This project started because I wanted to learn how to build a language. The homelab came second - I needed something worth compiling, and I had 33 Docker Compose files that were already a mess. What I didn’t plan for was the third thing, which is that by the time I was sketching the grammar it was obvious Claude would be writing most of the .hll files rather than me. So I stopped asking what I wanted to type, and started asking what a model would get right on the first try.

I want to be careful here, because this is the part of the project I was most pleased with - and also the part I got most wrong.

Grammar

The obvious moves came first, and I think they’re the least interesting:

  • keep the shapes close to Compose and YAML, since that’s what’s already dense in anything a model has read;
  • name every field, so nothing depends on argument order (there is no expose(80, "host", true) in this language, and I’m glad of it); and finally,
  • put my shared conventions in templates anyone can open and read, instead of leaving them as things I happen to know.

Then there’s the rule I wrote down and then broke within the week: one canonical way to say each thing. I believed that one. Multiple spellings of the same config give you inconsistent generations, and inconsistent generations are how I ended up with the drifting Compose files I complained about in post 1. Then I shipped a piece of sugar for routing, kept the longhand alongside it, checked that the two produced byte-identical output, and told myself I’d kept the sugar because I liked writing it. Which is an argument about my own convenience, made while designing a language for something else to use.

Both spellings are gone now. Not because I came to my senses about the rule - because routing turned out not to belong in the language at all, and took its two spellings with it when it left. I got the outcome the rule asked for and none of the credit, which is roughly what I deserved.

(I’m still breaking it elsewhere, mind you - the shorthand that lets me write image "nginx" instead of image { ref: "nginx" } breaks it everywhere, and I have no plans to remove that one.)

The loop matters more than the grammar

So which of those actually mattered? Not the grammar, as it turns out. My grammar choices are one-shot - a model either guesses the syntax or it doesn’t, and I only get to influence that once, months earlier, while writing a spec. The compiler is different, because the compiler gets to answer back.

hllc check compiles a file, writes nothing to disk, and fails loudly if anything is wrong - that’s the entire mechanism, and it turned out to be enough. Claude writes a file, check complains, Claude fixes it, and I never see the three bad versions in between!

I’ll give my design notes credit, too. They ranked the tooling ideas above the grammar ideas at the time, under a heading that says so in as many words. What I got wrong was which tooling.

Because if the compiler is going to answer back, what it says starts to matter enormously. My favorite one used to catch a comma that would change a Traefik label’s meaning without telling anyone - and rather than just refusing, it told me exactly what to write instead:

b.hll:6:18: `router.entrypoints` must not contain ',' — it would change the
meaning of the generated Traefik label — `entrypoints` is a list, so write
the entry points as separate items (`entrypoints web, websecure`) and let
`hllc` join them

I wrote that for me, on the theory that future-me would be baffled. It worked just as well on a model, and I never had to change a word of it. The repair is in the message, so nobody has to go and read the spec to make progress. That’s the whole trick, and I don’t think it’s really an AI trick.

Past tense, because it doesn’t exist any more. Routing left the compiler a few weeks ago, and that message was only ever possible while hllc knew what an entry point was. Hand it the same comma today and it writes the label out and says nothing - a label value is a string, and a string with a comma in it is a perfectly good string! The trade was worth making, since the alternative is my compiler chasing somebody else’s syntax across their releases forever. It still cost something, and I’d rather write that down than pretend it didn’t.

What I planned and didn’t build

My notes are very confident that the diagnostics should be structured and machine-readable, JSON carrying a line and a field and an expected type. I never built any of it. hllc prints prose, and my own versioning rules say the exact wording isn’t a stable contract, so nothing should be parsing it anyway. What I promise is the exit code, and the exit code is the only part the loop ever needed (a humbling result for the notes).

There’s one more I still haven’t written: the explain command I keep promising, which would tell me which tier set a given value.

Values

Field names are always checked. Values get checked where the legal set is short and closed, so a mistyped depends_on condition gets me told off by name. Everywhere else I’m on my own, and restart is the gap that bothers me most:

service s {
  image "n"
  restart always-on
}

always-on isn’t a Docker restart policy - but it compiles clean, and docker compose config won’t flag it either, so I find out about it when the daemon refuses the container.

Then there’s a worse one, which I found by reading my own error message properly. The unknown-field hint is generic on purpose - it offers raw for anything it doesn’t recognize, because it can’t tell a Compose key I haven’t modeled yet from a plain typo. So I took its advice on a typo:

service s {
  image "n"
  raw {
    imag: "nginx"
  }
}

hllc accepts that happily! Compose does not:

validating out.yaml: services.s additional properties 'imag' not allowed

My own compiler talked me out of a caught error and into an uncaught one. I’m oddly delighted by that, and I’d never have found it if I hadn’t gone looking for something to admit.

Two more to go

The next one is the strangest thing that happened to this project: I spent a month taking a feature back out, and found out what my compiler had been missing the whole time. Then the last post, where I have to account for myself. If you want the grammar before either, it’s all in docs/DESIGN.md.


This post was written with the help of Claude, which also wrote a good deal of the compiler it’s about.