Posted on ::

If you read my last post, you know the shape of my config: WezTerm, Fish and a pi extension sharing tab state through OSC 1337 user variables. Three producers, one consumer, and a pile of non-trivial logic in the consumer — glyph priority, a seen-map per pane, a sleep guard, a tab-number cache. The kind of config where “just a dotfile” stopped being true a while ago.

Fennel is a Lisp that compiles to Lua, and its pitch is full Lua compatibility: you can use any function or library from Lua. There are two ways to run it. Ahead of time: fennel --compile config.fnl emits ordinary Lua, which you then load like any other .lua. Embedded: you ship fennel.lua — the library, not the executable — and the host does require("fennel").install().dofile("config.fnl").

So if a program accepts Lua as its configuration language, it accepts Fennel: WezTerm, Neovim, OpenResty, Redis.

Tests

So now I get to unit test my WezTerm config. The pure logic runs under Faith, and the host-facing handlers run against a fake wezterm.

Starting module tests.wezterm.logic_test with 11 test(s)
...........
Starting module tests.wezterm.config_test with 9 test(s)
.........

Testing finished in approximately 0 second(s) with 76 assertion(s)
20 passed, 0 failed, 0 error(s), 0 skipped
tests/wezterm (real host): config loads in wezterm

The last line is not Faith: wezterm-test compiles config.fnl to Lua and asks the real WezTerm to evaluate it, which is the only check that touches the actual API rather than the fake.

What Fennel actually bought

The syntax is denser. Let me show you the places where it changed how the code reads: how absence works, how filtered lists get built, how you reach into nested data, and how state moves.

Nested access is one form

The old config shelled out to jq for the AirPods battery:

local function airpods_status()
  local ok, stdout = wezterm.run_child_process {
    'sh', '-c',
    "system_profiler SPBluetoothDataType -json 2>/dev/null | jq -r '.SPBluetoothDataType[0].device_connected[]? | to_entries[] | select(.value.device_minorType == \"Headphones\") | [.value.device_batteryLevelLeft, .value.device_batteryLevelRight] | map(capture(\"(?<n>[0-9]+)\").n) | if length > 0 then min else empty end'",
  }
  local pct = ok and stdout:match('(%d+)')
  if not pct then return '' end
  return wezterm.format{ { Foreground = { Color = dim_color } }, { Text = nf.md_headphones .. pct } }
end

With Fennel I dropped jq and parsed the Bluetooth JSON in-process.

In Fennel the same reach is one form, ?., and the whole extraction reads as a functional pipeline:

(fn airpods_min [data]
  (let [connected (?. data :SPBluetoothDataType 1 :device_connected)
        infos (icollect [_ dev (ipairs (or connected []))]
                (let [k (next dev)] (when k (. dev k))))]
    (min_of (icollect [_ info (ipairs infos)]
              (when (= info.device_minorType "Headphones")
                (device_min info))))))

State is a value

The tab glyph depends on a small piece of state: which attention token I have already seen for a pane. The Lua version touches that map in three branches:

local function tab_glyph(pane, tab, vars)
  local att = vars.attention
  if not live(att) then
    attention_seen[pane.pane_id] = nil
    return live(vars.running) and nf.md_play or nil
  end
  if attention_seen[pane.pane_id] == att then return nil end
  if tab.is_active then
    attention_seen[pane.pane_id] = att
    return nil
  end
  return nf.md_record
end

The Fennel version is a pure transition function in wezterm-logic.fnl: it takes the current seen value and returns the next one alongside the glyph.

(fn tab_glyph [att running seen is_active]
  (if (not (live att))
      (values (if (live running) :running nil) nil)
      (= seen att)
      (values nil seen)
      is_active
      (values nil att)
      (values :attention seen)))

The config side owns the map and does the single mutation:

(fn render_glyph [pane tab vars]
  (let [pid pane.pane_id
        (glyph next-seen) (logic.tab_glyph vars.attention
                                           vars.running
                                           (. attention_seen pid)
                                           tab.is_active)]
    (tset attention_seen pid next-seen)
    (if (= glyph :attention) nf.md_record
        (= glyph :running) nf.md_play
        nil)))

The state never leaves one place, and the rule that decides the next state is plain data-in, data-out — which is why it has its own test.

The drop-in question

What “drop-in” hides is the build question, and that is where all the friction lives.

  • WezTerm: config.fnl compiles to ~/.wezterm.lua. I regenerate it on every home-manager switch, with --correlate so Lua tracebacks point at Fennel line numbers, and --require-as-include so the logic module is inlined via package.preload and the shipped file is still a single artifact. There is a wezterm-regen script for the edit loop.

  • The other way: Fennel can also run at load time — drop fennel.lua somewhere on the host’s package.path and require("fennel").install().dofile("config.fnl"). That needs the Fennel library (not the executable) reachable by the host. I chose the generated file to avoid that whole class of surprises.

So: yes, anywhere Lua is the config language, Fennel is a realistic replacement.

And it is not only configuration. Lua is an embedding language first: Redis runs Lua for EVAL and server-side Functions, OpenResty runs Lua inside nginx, Neovim runs Lua for plugins, and countless tools and engines embed it. Any of those can be written in Fennel and compiled to Lua, and where the host can load a module, Fennel can even run at load time. I am not doing that today, but it is why I stopped filing Fennel under “a prettier config language” and started filing it under “Lua with better syntax, everywhere Lua already runs”.

Verdict

The language is the least interesting part of this. Fennel made the rewrite pleasant enough that I actually did it, and the functional bits — icollect, when-returns-nil, nil-safe ?. — made the data-flow code read better than the Lua did. But the thing that made the config robust was the split and the tests, and you could do that in Lua tomorrow.

So I now get an evolving WezTerm config with a cleaner functional approach. That was the main reason I got my feet wet with my first Lisp ever (hopefully not the last, since I am hoping to use Fennel in Redis and other Lua hosts too).

Written with LLM assistance; the config is real and really deployed.