Convert legacy Hyprland hyprland.conf (hyprlang) files
to the Lua configuration format introduced in Hyprland 0.55 (May 2026).
Try it online: https://eiontusk.github.io/hyprlang2lua/ — runs entirely in your browser, no input ever leaves the page.
The converter is built around a hand-written lexer + recursive-descent parser
and a per-directive code generator. The output is idiomatic Lua that matches
the shape of the example config shipped at /usr/share/hypr/hyprland.lua,
mapping each hyprlang construct to the hl.* API exposed by the Lua stubs
at /usr/share/hypr/stubs/hl.meta.lua.
paru -S hyprlang2lua # or: yay -S hyprlang2lua
The PKGBUILD source lives at packaging/aur/PKGBUILD; release notes for
maintainers are in packaging/aur/MAINTAINING.md.
nix run github:EIonTusk/hyprlang2lua -- input.conf > output.lua
nix profile install github:EIonTusk/hyprlang2lua # persistent install
nix develop opens a dev shell with the Go toolchain, gopls, and lua
(used by the optional luac -p gate in the golden tests).
go install github.com/EIonTusk/hyprlang2lua/cmd/hyprlang2lua@latest
…or build locally:
go build -o hyprlang2lua ./cmd/hyprlang2lua
Go 1.26+. The library (internal/converter) is standard-library only; the
CLI adds a single dependency, github.com/spf13/pflag, for POSIX-style flag
parsing.
A WebAssembly build is included under web/ for an in-browser converter — see
Browser build below.
# single file → stdout
hyprlang2lua ~/.config/hypr/hyprland.conf > ~/.config/hypr/hyprland.lua
# from stdin
cat hyprland.conf | hyprlang2lua > hyprland.lua
# write next to each *.conf in a tree
hyprlang2lua --dir ~/.config/hypr
# show coverage stats on stderr
hyprlang2lua --report hyprland.conf > hyprland.lua
# CI mode: exit non-zero if anything was flagged for manual review
hyprlang2lua --check hyprland.conf > /dev/null
Flags:
| flag | effect |
|---|---|
-d, --dir DIR |
walk a directory, writing *.lua next to every *.conf |
--in-place |
with --dir, overwrite existing *.lua siblings (off by default) |
-o, --out FILE |
write to FILE (single-file mode; default stdout) |
-r, --report |
print translated / passthrough / flagged / coverage% to stderr |
-c, --check |
exit code 3 if any directive was flagged for manual review |
--no-merge |
emit a separate hl.X(...) call per source line instead of merging mergeable APIs into one call. Merging is on by default and currently applies to hl.config — in practice it folds every per-section hl.config({...}) into one call, with section-separating comments preserved inside the merged table. Other hl.* APIs (bind, window_rule, monitor, env, device, …) take one spec per call by design and pass through unchanged. |
--no-polyfill |
disable runtime Lua helper closures used to preserve hyprlang features without a direct Hyprland 0.55 typed-API equivalent (currently: percent-form resizeactive/moveactive, source globbing). Polyfill is on by default; passing this flag forces strict output and flags any such feature for manual review instead. |
--hoist-vars |
move every $var = ... rewrite into a single block at the top of the output instead of emitting each local in source position |
-s, --strip-comments |
drop comments from the output (-- TODO: manual review markers from flagged directives are kept) |
With no positional argument, the CLI reads from stdin — unless stdin is a TTY, in which case it prints usage instead of hanging on a read.
Exit codes: 0 success, 1 I/O or conversion error, 2 usage/flag error,
3 --check failed (at least one flagged directive).
key = value at the top level and inside any of the recognized sections:
general, decoration, input, animations, gestures, misc,
binds, cursor, debug, dwindle, master, group, render,
xwayland, opengl, ecosystem, experimental, layout,
scrolling, quirks. Nested sections (decoration { blur { } }) emit
nested Lua tables.$var = value → local var = value. References ($var on the right side
of any directive) resolve to the local; mixed text builds a concat chain
(mainMod .. " + SHIFT + 1").bind family — bind, bindm, binde, bindr, bindl, bindn,
bindo, bindt, bindi, bindp, bindc, bindd, and any combined-flag
form like bindel / bindle. Each flag suffix becomes the corresponding
field on HL.BindOptions.exec, exec-once, execr-once, exec-shutdown. Bundled into one
hl.on("hyprland.start", function() ... end) (or config.reloaded /
hyprland.shutdown) block per kind.monitor, windowrule, windowrulev2, workspace, layerrule,
env, envd, animation, bezier, gesture, permission.device:<name> { ... }, mapped to hl.device({ name = "<name>", ... }).# comment → -- comment, in roughly the same source position.source = path → require("path") plus a comment reminding the user that
the sourced .conf must itself be converted. Reason: require() integrates
with Lua's package.path and preserves the user's modular structure;
dofile() would force relative paths, and inline-expansion would bloat
output and discard organization.source = pattern/*.conf (glob) → hl_source_glob("pattern/*.lua") when
--polyfill is on. The runtime helper shell-expands the pattern via ls
and dofiles each match. With --no-polyfill the directive is flagged.submap = name / submap = reset — bind directives between the two
markers are buffered and emitted as a single hl.define_submap("name",
function() ... end) block. Non-bind statements inside the block flush
the buffer first, so source order survives even on weird input.plugin { name { ... } } — plugin sections are passed through as a Lua
comment block with a TODO, since each plugin exposes its own keys under
hl.plugin.<name> and we can't safely guess the API.env = K, V → hl.env(K, V) (no propagation). envd = K, V →
hl.env(K, V, true) — hl.env's third arg is the dbus boolean per
Hyprland source (src/config/lua/bindings/LuaBindingsConfigRules.cpp):
when true, hyprland calls systemctl --user import-environment and
dbus-update-activation-environment --systemd for that variable.execr-once = cmd → hl.dispatch(hl.dsp.exec_raw(cmd)) inside the
hyprland.start hook. exec_raw is the native dispatcher per the
Dispatchers wiki:
"execute a raw command. While exec_cmd will do sh -c, this won't."$VAR inside exec strings — preserved verbatim in the emitted
Lua string so the downstream /bin/sh -c expansion still sees the sigil
(the same fallback hyprlang uses for $HOME / $XDG_*). Undeclared $X
in non-shell contexts (bind keys, config values, dispatcher table fields)
still rewrites to a Lua local so the missing declaration surfaces as a
clear load-time error instead of a silently non-matching bind.gaps_in = 5 10 15 20, etc.) — parsed into the
typed HL.CssGap table { top, right, bottom, left } per CSS box-shorthand
rules. Applies to general.gaps_in / gaps_out / float_gaps and
monitorv2's reserved / reserved_area.resizeactive / moveactive (e.g. resizeactive 10% 5%) and
the *windowpixel / exact variants. The 0.55 typed dispatch API only
accepts numeric pixels, so the converter emits a small runtime closure that
resolves the percent at dispatch time against the active window or monitor
and then calls hl.dispatch(...). Disable with --no-polyfill to flag
these instead.signal, signalwindow, setprop (WIN PROP VAL [lock]),
tagwindow (TAG [WIN]), alterzorder (MODE[,WIN]),
fullscreenstate (INTERNAL CLIENT [ACTION]), fakefullscreen /
togglefakefullscreen, lockactivegroup, lockgroups,
denywindowfromgroup, changegroupactive, moveintogroup,
moveoutofgroup (WINDOW selector, not direction), movewindoworgroup,
movegroupwindow, cyclenext, swapnext, swapwindow,
renameworkspace, moveworkspacetomonitor, swapactiveworkspaces,
focusworkspaceoncurrentmonitor. Legacy action vocabularies
(f/b, on/off, 1/0) translate to the new string forms
(next()/prev(), set/unset, lock/unlock).killactive → close() (graceful, despite the name); closewindow SEL
→ close(SEL); killwindow SEL → kill(SEL) (actually SIGKILL per
the 0.54 wiki); forcekillactive → kill().movecurrentworkspacetomonitor MON → inline closure resolving
hl.get_active_workspace().id and dispatching workspace.move({workspace, monitor=MON}).loadconfig → inline function() hl.exec_cmd("hyprctl reload") end.
No dispatcher equivalent in 0.55+; the one-liner makes a helper
preamble unnecessary.bind = ..., exec, hyprctl dispatch X args — detected and rewritten to
the direct hl.dsp.* call so the bind doesn't pay an exec per keypress.
Other hyprctl subcommands (reload, keyword, notify, …) stay as
literal hl.dsp.exec_cmd calls.Anything not in either list is preserved with a -- TODO: manual review
comment, contributes to flagged in the report, and trips --check.
internal/converter/ pure Go — lexer, parser, AST, Lua codegen.
no os, no net, no filesystem. wasm-compatible.
single entry point: Convert(src) -> (lua, Report, err)
cmd/hyprlang2lua/ thin CLI wrapper over the converter package.
The core is deliberately I/O-free so the same code backs both the CLI and
the WebAssembly build at web/wasm/main.go.
web/ contains a self-contained converter UI that runs entirely client-side
— the conversion happens in WebAssembly compiled from the same
internal/converter package, so no input ever leaves the page.
Build the wasm artifact and serve the directory:
cd web/wasm
GOOS=js GOARCH=wasm go build -o ../main.wasm .
cd ..
python3 -m http.server 8080 # or any static server
Then open http://localhost:8080/. The wasm module exposes a single global,
window.hyprlang2lua.convert(src), returning { lua, translated, passthrough,
flagged, coverage, notes, error }.
The browser build is deployed to GitHub Pages by .github/workflows/pages.yml
on every push to main/master that touches the converter, web/, or the
module graph. The workflow builds main.wasm from source, stages the static
assets into site/, and hands them to actions/deploy-pages@v4.
One-time repo configuration: Settings → Pages → Build and deployment → Source must be set to "GitHub Actions" (not "Deploy from a branch"). Without that, the deploy step fails with a 404. Trigger manually via the Actions tab → pages → Run workflow if a redeploy is needed without a code change.
go test ./...
go test ./internal/converter -fuzz FuzzConvert -fuzztime 30s # quick fuzz
go test ./internal/converter -run TestGolden -update # refresh goldens
Golden fixtures live in internal/converter/testdata/; each .conf is
paired with the expected .lua output. FuzzConvert exercises the lexer
and parser against random byte sequences to catch panics.
PRs are squash-merged, so the PR title becomes the master commit subject —
and the release workflow (.github/workflows/release-on-merge.yml) reads
that subject to decide whether to cut a release and how to bump the version.
Use the Conventional Commits format:
| PR title prefix | Release |
|---|---|
feat: / feat(scope): |
minor |
fix: / fix(scope): |
patch |
feat!: / fix!: / <type>(scope)!: |
major |
commit body contains BREAKING CHANGE: |
major |
chore: / docs: / refactor: / ci: / test: / non-conventional |
no release |
When a release-triggering commit lands on master, the workflow tags the
version, regenerates packaging/aur/PKGBUILD + .SRCINFO, bumps
flake.nix, fixes up a stale vendorHash if needed, opens a GitHub
Release, and pushes to the AUR. None of that requires anything from you —
just title the PR correctly.
If a release fails or you need an out-of-band cut, dispatch the workflow
manually from the Actions tab → release-on-merge → Run workflow and
pick patch / minor / major.
Mappings were derived from, in priority order:
/usr/share/hypr/stubs/hl.meta.lua — the autogenerated Lua API stubs
shipped with Hyprland 0.55 (definitive list of hl.* functions, the
HL.ConfigKey set, and every *Spec type)./usr/share/hypr/hyprland.lua — the shipped example, used as a style
reference for idiomatic table layout.MIT.
—
$ claude mcp add hyprlang2lua \
-- python -m otcore.mcp_server <graph>