One of my main goals during my end-of-year break was to update my crusty old Neovim config I copied from Vim ages ago into Lua. However, a random post I saw about Conjure piqued my interest. Through a bit of research, I found out the plugin was written in Fennel. Further, there were people both succeeding and failing to use Fennel to write their plugins and Neovim configurations. With curiosity at an all-time high and my love of Lisps thoroughly stoked, I decided to take a crack at moving my Neovim config to Fennel instead.
Fennel?
Fennel is a programming language that brings together the simplicity, speed, and reach of Lua with the flexibility of a Lisp syntax and macro system.
Lisps are cool. Lua is cool. So by extension, Fennel is really cool.
The value prospect is simple: cross-compile to Lua. That simple idea has a lot of potential, especially since Lua is used in a lot of novel places. Now any Lua integration is a Fennel integration. From Neovim to game frameworks like LÖVE. It's pretty awesome.
In Lisps, almost everything is an expression that resolves to a value. That simple concept lends itself well to being used for non-trivial configuration.
But how?
Enter nfnl.
Enhance your Neovim experience through Fennel with zero overhead. Write Fennel, run Lua, nfnl will not load unless you're actively modifying your Neovim configuration or plugin source code (nfnl-plugin-example, my Neovim configuration).
I won't bore you with all the details of the README. But two important notes for our purposes are:
- Automatically compiles _.fnl files to _.lua when you save your changes.
- Compiles your Fennel code and then steps out of the way leaving you with plain Lua that doesn't require nfnl to load in the future.
And it's as simple as banging a { "Olical/nfnl", ft = "fennel" } into your Lazy config and hitting a:
shell
echo "{}" > ~/.config/nvim/.nfnl.fnl
With that (and a proper Fennel install) .fnl files in your config directory cross-compile to their equivalent .lua files. One cool convenience is ~/.config/nvim/fnl directory compiles to ~/.config/nvim/lua, which is very handy. Under the hood, you're just running officially supported Lua in Neovim.
Okay... I need a bit more than that
Disclaimer: I'm a Mac user. I will be using my normal commands and I leave finding equivalents as an exercise to the non-Mac-user and non-Homebrew-user readers.
I also assume familiarity with the shell and that you are already rocking Lua in Neovim.
Happy to help!
It's actually relatively easy and I modeled my approach after Oliver Caldwell's dotfiles. To make this work as easily as possible, I recommend starting off just getting Lazy and nfnl up and running. To do so, you'll need to back everything up and then get the bootstrap going. Our basic blueprint is essentially:
|_.config/
|_ nvim/
|_ .nfnl.fnl
|_ init.lua
|_ init.fnl
|_ fnl/
|_ config/
|_ macros.fnl
|_ plugins/
|_ nfnl.fnl
|_lua/
|_ config/
|_ lazy.lua
First things first
To make things simple and avoid issues, we'll need a clean slate. To do so, you should just back up your nvim configuration. If you're already deep into Neovim configuration, you'll be able to bring what you want back later if you want to.
mv ~/.config/nvim ~/.config/nvim.bak
Secondly...
Create the basic directories you need.
mkdir -p ~/.config/nvim/fnl/config ~/.config/nvim/fnl/plugins ~/.config/nvim/lua/config
Third
Create a couple of very similar Lua files:
~/.config/nvim/init.lua
-- Bootstrap lazy.nvim
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not (vim.uv or vim.loop).fs_stat(lazypath) then
local lazyrepo = "https://github.com/folke/lazy.nvim.git"
local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath })
if vim.v.shell_error ~= 0 then
vim.api.nvim_echo({
{ "Failed to clone lazy.nvim:\n", "ErrorMsg" },
{ out, "WarningMsg" },
{ "\nPress any key to exit..." },
}, true, {})
vim.fn.getchar()
os.exit(1)
end
end
vim.opt.rtp:prepend(lazypath)
-- Setup lazy.nvim
require("lazy").setup({
spec = {
{ "Olical/nfnl", ft = "fennel" }
},
-- automatically check for plugin updates
checker = { enabled = true },
})
~/.config/nvim/lua/config/lazy.lua
-- Bootstrap lazy.nvim
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not (vim.uv or vim.loop).fs_stat(lazypath) then
local lazyrepo = "https://github.com/folke/lazy.nvim.git"
local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath })
if vim.v.shell_error ~= 0 then
vim.api.nvim_echo({
{ "Failed to clone lazy.nvim:\n", "ErrorMsg" },
{ out, "WarningMsg" },
{ "\nPress any key to exit..." },
}, true, {})
vim.fn.getchar()
os.exit(1)
end
end
vim.opt.rtp:prepend(lazypath)
-- Setup lazy.nvim
require("lazy").setup({
spec = {
-- import your plugins
{ import = "plugins" },
},
-- automatically check for plugin updates
checker = { enabled = true },
})
The difference between them is subtle and boils down to ~/.config/nvim/lua/config/lazy.lua importing from the plugins directory rather than installing plugins directly:
spec = {
-- import your plugins
{ import = "plugins" },
},
Which allows us (through the magic of nfnl) to simply create spec files in ~/.config/nvim/fnl/plugins and easily manage plugin installation and configuration in Fennel.
Fourth!!
Now, in order to make the cutover smooth, we need to add our first spec. To do so, first we should create a simple macro to make our lives easier:
~/.config/nvim/fnl/config/macros.fnl
;; fennel-ls: macro-file
;; [nfnl-macro]
;; Macros
(fn tx [& args]
"Mixed sequential and associative tables at compile time. Because the Neovim ecosystem loves them but Fennel has no neat way to express them"
(let [to-merge (when (table? (. args (length args)))
(table.remove args))]
(if to-merge
(do
(each [key value (pairs to-merge)]
(tset args key value))
args)
args)))
{: tx}
And then the spec:
~/.config/nvim/fnl/plugins/nfnl.fnl
(import-macros {: tx} :config.macros)
(tx "Olical/nfnl" {:ft "fennel"})
Which should also be cross-compiled to ~/.config/nvim/lua/plugins/nfnl.lua if everything is running properly.
And finally...
Now it's time to create a simple init.fnl:
~/.config/nvim/init.fnl
(require :config.lazy)
You should also have access to :NfnlCompileFile in Neovim when editing .fnl files if everything is running properly.
To complete the cutover, all you need to do is open ~/.config/nvim/init.fnl in Neovim, rm ~/.config/nvim/init.lua, and then run :NfnlCompileFile. With that, a new init.lua cross-compiled from your init.fnl should be generated and your cutover is complete. .fnl files you create and edit should now get cross-compiled to Lua which are then run directly in Neovim like normal.
You can now move your old config pieces you want back in or rebuild with Fennel.
Can you show some examples?
Certainly!
~/.config/nvim/init.fnl
;;; Options
(set vim.g.mapleader " ")
(set vim.g.maplocalleader " ")
(set vim.o.termguicolors true)
(set vim.wo.number true)
(set vim.o.shiftwidth 4)
(set vim.o.softtabstop 4)
(set vim.o.expandtab true)
(require :config.lazy)
~/.config/nvim/fnl/plugins/nvim-tmux-navigation.fnl
(import-macros {: tx} :config.macros)
(tx "alexghergh/nvim-tmux-navigation"
{:config
(fn []
(let [nav (require :nvim-tmux-navigation)]
(nav.setup
{:disable_when_zoomed true
:keybindings { :up :<C-k>
:down :<C-j>
:left :<C-h>
:right :<C-l>
:last_active :<C-\>
:next :<C-Space>}})))})
~/.config/nvim/fnl/plugins/surround.fnl
(import-macros {: tx} :config.macros)
(tx "kylechui/nvim-surround"
{:event "VeryLazy"
:opts {}
:config
(fn []
(let [ns (require :nvim-surround)]
(ns.setup {})))})
~/.config/nvim/fnl/plugins/treesitter.fnl
(import-macros {: tx} :config.macros)
(vim.api.nvim_create_autocmd
"FileType"
{:pattern ["*"]
:callback #(vim.schedule #(pcall #(vim.treesitter.start)))})
(tx
"nvim-treesitter/nvim-treesitter"
{:main :nvim-treesitter.configs
:branch "main"
:build ":TSUpdate"
:config
(fn []
(let [ts (require :nvim-treesitter)
languages [:query
:clojure
:vimdoc
:gitattributes
:gitcommit
:regex
:bash
:markdown
:markdown_inline
:vim
:lua
:fennel]]
(ts.install languages)))})
Anything else to watch for?
Well, if you install Mason and such (highly recommend looking at Olical's dotfiles for a cool LSP config) and you're using the Fennel language server for development, you're going to want to create a flsproject.fnl file in order to avoid a lot of red in your editor:
~/.config/nvim/flsproject.fnl
{:macro-path "fnl/config/macros.fnl"
:library {:nvim true}}
On top of that, the vim global may be unknown. It still works fine but hitting a (global vim vim) at the top is an easy workaround to get rid of the errors without having a negative effect.
Another important tidbit worth reiterating is that Lua is still what's being run here. So if you need to use Lua, you can. One example in this article is our Lazy config which is also required from Fennel (and by extension our cross-compiled Lua) and works wonderfully.
Which leads nicely into the final pitfall. You can only require files that are in Lua. The Fennel code doesn't actually run and if an accompanying Lua file is not generated, that code just doesn't exist and can't be used. Some Fennel constructs like macro files are a little different but by and large this rule runs true.
Done!
And that's how you easily get up and running with Fennel for Neovim configurations. Mine still isn't where I'd necessarily like it and I'm iteratively improving it. Long term, I'll be building a simple DSL for composing my configuration. Information and help is a bit scant with running Fennel for Neovim configurations and plugins, but hopefully this helps other folks avoid the initial pitfalls I ran into.
It's worth noting that there are a few options besides nfnl, like Aniseed and Tangerine. I tried them but didn't end up going with them because the simplicity of nfnl is awesome. After all is said and done, you get all the fun and benefits of Fennel but you're still just running native Lua in Neovim keeping everything nice and simple.
There's also some configuration options for nfnl that are worth exploring to keep everything clean. Like only compiling from certain directories. I'll leave that to you if you're interested in exploring more.
Top comments (0)