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

The hllc command-line tool

hllc is the hll compiler’s command-line binary. It has four subcommands, each taking a single positional path argument:

hllc build <file.hll or directory> [--out <path>] [--force]
hllc check <file.hll or directory>
hllc parse <file.hll>
hllc tokens <file.hll>

Each one runs the compiler to a different depth. tokens stops after the lexer, parse after the parser, and build and check run all of it—differing only in where each one puts the document it generates.

hllc on its own prints this list and exits non-zero: it compiles nothing until you name one of the four.

build

Runs the full pipeline—parse, resolve use imports, resolve template/with composition, generate Compose YAML—and either prints the result or writes it to disk. This is the one you’ll use in practice.

Single file

hllc build jellyfin.hll                            # prints YAML to stdout
hllc build jellyfin.hll --out docker-compose.yml   # writes to a path
hllc build jellyfin.hll --out dist/                # writes dist/docker-compose.yml

One input file always produces one output document, though it may hold multiple service declarations—see Getting Started. build fully resolves any use graph the file participates in, so building syncthing.hll from the Imports example produces the same output whether its templates live in the same file or across three use-connected ones.

If --out names an existing directory, hllc writes docker-compose.yml inside it—the same convention directory mode uses, so dist/—the natural first guess—works whether the input is a single file or a whole directory.

Directory: flat mode

Point build at a directory instead of a file, and hllc treats every .hll file directly inside it as its own independent entry point, each with its own use graph:

services/
  jellyfin.hll
  syncthing.hll
  uptime-kuma.hll
hllc build services/ --out dist/

--out is required in this mode—with potentially many files’ worth of output, there’s no single meaningful default location. Each file’s stem becomes its own output directory: dist/jellyfin/docker-compose.yml, dist/syncthing/docker-compose.yml, and so on.

hllc skips, rather than builds, a file that declares no service—one holding only template/network declarations meant to be used by others: building it would produce a Compose document with nothing in it, since codegen only ever emits what a service actually references.

Directory: co-located mode

hllc chooses this mode automatically when the target directory holds no .hll files directly—the layout a real homelab tends to use in practice, keeping each service’s .hll source next to its other files (.env, bind-mounted config), often alongside a shared library of templates and networks used by every service:

homelab/
  shared/
    network.hll
    templates.hll
  services/
    jellyfin/
      jellyfin.hll
      .env
    syncthing/
      syncthing.hll
hllc build homelab/

hllc recurses through the tree looking for service directories—a directory holding exactly one .hll file that declares a service— however many levels down they sit. A directory that isn’t one itself (like homelab/ or services/ in the preceding listing, which hold no .hll files of their own) is recursed into. hllc recognizes a library directory (like shared/, which holds .hll files but none of them declare a service) as such and skips it, rather than treating it as a malformed service directory. Only service declarations count for this—a file that uses a shared library of templates is still a service directory in its own right.

With no --out, each service directory’s .hll file builds in place, right back into that same directory: services/jellyfin/docker-compose.yml, services/syncthing/docker-compose.yml. An explicit --out <dir> still remaps the whole tree, the same way flat mode’s does, but preserving each service directory’s path relative to the build root instead of flattening by name: <out>/services/jellyfin/docker-compose.yml.

A directory containing more than one .hll file that declares a service is a hard error—it’s ambiguous which one’s output belongs directly in that directory, so hllc won’t guess. A directory can freely mix one service file with any number of library files, though—only the count of service-declaring files matters.

Generated files, and what hllc won’t overwrite

Every document build produces—printed or written—starts with a header marking it as generated:

# Generated by hllc—do not edit.
# Edit the .hll source and re-run `hllc build` instead.
services:
  jellyfin:
    ...

It’s an ordinary YAML comment, so docker compose ignores it, and it makes generated files self-identifying in a repo, in a diff, and in review.

hllc also reads that header back. Before writing any output file it checks what’s already there:

  • Nothing there, or a file carrying the header (hllc’s own earlier output) → written, as always. Rebuilding never needs a flag.
  • A file without the header—a hand-written docker-compose.yml, or anything else—→ refused, with an error, and the build exits non-zero without touching it.
  • A symlink → refused as well, whatever it points at. Replacing the link is up to you, since only you can see its target.

This matters most in co-located mode, which scans for its output paths instead of taking them as input: converting a repo one service at a time means running hllc build . over directories whose docker-compose.yml files are still hand-written, and those are exactly the files hllc must not clobber.

Pass --force when you do want an unmarked file replaced—typically the one-time conversion of a service you’ve just rewritten in .hll:

hllc build services/jellyfin/jellyfin.hll --out services/jellyfin/docker-compose.yml --force

--force skips only the header check. The symlink refusal stands regardless.

Which directory mode applies

hllc inspects the target directory once and picks a mode:

  • Any .hll files directly inside it → flat mode, --out required. hllc skips files that declare no service.
  • No .hll files directly inside it → co-located mode, recursing into subdirectories to find service directories at any depth. A directory found along the way that declares no service (whether it holds no .hll files, or only library ones) is recursed into rather than treated as a service directory.
  • No service directory found anywhere in the tree → builds nothing, successfully, but prints a line saying so—a directory build that quietly does nothing is easy to mistake for one that worked.

check

Runs everything build runs and writes nothing. It’s the CI gate: exit 0 means every entry point it found compiles, and a non-zero exit means one didn’t, with the diagnostic on stderr.

hllc check jellyfin.hll
hllc check services/
hllc check homelab/

A run that passes prints nothing at all—there’s no document to show and no path to report. Both directory shapes work exactly as they do under build, walked the same way and choosing the same mode, so a whole tree checks in one command. Flat mode needs no --out here: build requires one because it has however many files’ worth of output to place, and check places none.

check writes nothing anywhere—which matters most in co-located mode, where build with no --out writes each document back into the service directory it found. Nothing appears in the tree you checked, and nothing needs cleaning up afterwards.

Warnings still print, and still don’t fail the run, exactly as under build.

parse

Parses one file and pretty-prints its Abstract Syntax Tree (AST), without resolving use imports or with composition and without generating any output. Useful for understanding how a particular shorthand desugars:

hllc parse jellyfin.hll

It’s a debugging aid, and its output is often thousands of lines, so it’s usually worth a pager: hllc parse jellyfin.hll | less.

tokens

Runs the lexer over one file and prints its token stream, one token per line as line:col kind lexeme—for debugging the lexer itself, not something you’d reach for day to day:

hllc tokens jellyfin.hll

Exit codes

hllc exits non-zero on any lex/parse/link/Compose/codegen error, printing a diagnostic to stderr—hllc check is safe to use directly as a CI gate before docker compose up. An invocation hllc can’t make sense of—no subcommand, an unknown one, a missing path, a flag on a subcommand that doesn’t take it—exits 2 without touching any file.

How much of a location the diagnostic carries depends on the stage that raised it:

  • Lex errors print path:line:col: message.

  • Parse errors print path: line:col: message—the path, then a space, then the position, so the path isn’t part of the line:col sequence.

  • Link errors about a file as a whole (an import that won’t load, a duplicate alias) name that file, which may be an imported one rather than the file you passed on the command line.

  • Compose and codegen errors print path:line:col: message, and every position they mention carries its own path. A composed service’s fields can come from any file in the use graph, so an error about two of them routinely straddles two files:

    t2.hll:2:11: field `restart.policy` set by both template `x` (at t1.hll:2:11) and template `y`—explicit templates must not conflict
    

    Both positions here are line 2, column 11—in different files. The path on each is what distinguishes them, and it points at the file the field was really written in, which may be an imported one you never opened.

Warnings

Not everything hllc has to say is fatal. Some of what you can write is legal, deliberately dropped, and still worth hearing about, since a declaration that compiles to nothing at all looks exactly like one you forgot to write. Those are warnings. hllc prints each one to stderr in the same path:line:col: shape as an error, with a warning: marker after the location, and it changes neither the exit code nor the output. A build that raises only warnings still writes its files and still exits 0, so a warning can’t break a CI gate:

shared/common.hll:1:10: warning: template `defaults` is not applied to anything — it is an ordinary template now, no longer applied implicitly to every service; add `with defaults` to each service that wants it
shared/common.hll:6:9: warning: service `db` is declared in an imported file and is not compiled — only the entry file's services are built
jellyfin.hll:2:9: warning: network `unused` is declared but no service references it, so it is not emitted — add it to a service's `networks [...]` list, or remove the declaration

There are three of them today, one per construct that a stage drops on purpose:

  • a service in an imported file, since hllc builds only the entry file’s services—see Imports
  • a template defaults no service applies with a with, since defaults is no longer applied implicitly—see Templates & Composition
  • a top-level network no service references, since hllc builds the networks: section from services’ references

No flag silences them yet. When one is telling you about something you meant, the fix is to write it in a way that drops nothing, and the warning text names that fix in each case.

Routing used to add a fourth of these—a router with nothing to match was a hard error rather than a warning. Routing is templates now, so hllc has no view on whether a set of labels describes a working router. See Routing.