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
.hllfiles directly inside it → flat mode,--outrequired.hllcskips files that declare no service. - No
.hllfiles 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.hllfiles, 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 theline:colsequence. -
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 theusegraph, 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 conflictBoth 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
servicein an imported file, sincehllcbuilds only the entry file’s services—see Imports - a
template defaultsno service applies with awith, sincedefaultsis no longer applied implicitly—see Templates & Composition - a top-level
networkno service references, sincehllcbuilds thenetworks: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.