Skip to content

Configuring Neovim with Fennel

Posted on:November 8, 2023 at 07:00 AM

Configuring Neovim with Fennel

I like changing the configuration of my Neovim quite a lot, and recently I have reached a plateau where my configuration does everything I want, most people would be happy once they reach this point but I was not because I like moving stuff so I started looking for things that looked cool and I found many people talking about Fennel…

What is Fennel?

Fennel is a Lisp language that compiles to lua, it’s easy to learn and since learning lisp has been on my todo list for quite some time after I tried Emacs, I saw this as a good opportunity to do it.

Why Fennel?

If you like lisp languages or want to learn one, fennel is a good way to start since it’s quite a simple language that compiles to another simple language (lua) while being fully compatible with it and having zero overhead once compiled.

It has many syntax improvements over lua, but the most remarkable features of Fennel are:

No global variables by default

One of the things I didn’t like much about lua is how easy is to accidentally declare global variables by accident, when you look for many lua tutorials you see people using global variables without they knowing because it’s simply the default behavior:

-- This is a global variable
name = 'User 1'

-- Non-global variable
local name = 'User 1'

In most cases, you want to create a local variable, and fennel defaults to it every time you create a variable:

; Declare a local variable
(local name "User 1") ; Compiles to local name = "User 1"
(var language "fennel") ; Compiles to local language = "fennel"

Immutable variables by default

This behavior is becoming quite popular in other modern languages like Rust, which means that once you assign a value to a variable the value cannot change unless you explicitly allow it.

The advantage of this behavior is that it makes it easier to know how a variable is being used in your code just by looking at how it was defined

Examples in lua:

-- There's no way to know if the value can change in the code by just looking at it
local language = "lua"

-- You might or might not see this somewhere in your code
language = "fennel"

Fennel:

; Once you see this, you know this is a constant and cannot change
(local language "fennel")

(set language "lua")
; Last line will generate the error:
; Compile error: expected var language
;
; (set language "lua")
; * Try declaring language using var instead of let/local.
; * Try introducing a new local instead of changing the value of language.

Of course, you can still declare mutable variables with var:

(var language "fennel")

(set language "lua") ; It works...

It doesn’t declare variables with typos

In lua is very easy to redeclare new variables by mistake when you have a typo and when you do this you will probably only catch the error at runtime

local my_variable = 1

myvariable = 2 -- There's a typo in this line and this will just create a new global variable called myvariable

In fennel this cannot happen since the compiler will warn you at compile time:

(var my_variable 1)

(set myvariable 2)
; Compile error: expected local myvariable
;
; (set myvariable 2)
; * Try looking for a typo.
; * Try looking for a local which is used out of its scope.

Tables have a more familiar syntax

Another small change is that fennel uses a more traditional way to represent arrays and tables

; Table or dictionary
(local person {:name "User 1"}) ; => local person = {name = "User 1"}

; Array
(local numbers [1 2 3]) ; => local numbers = {1, 2, 3}

Destructuring

Fennel supports destructuring like many other modern languages

(local person {:name "User 1" :age 12})

; Destructuring table
(local {:name person_name} person)

(print person_name) ; => User 1

(local my-numbers [1 2 3 4 5])

; Destructuring a list
; & c means the rest goes to this variable
(local [a b & c] my-numbers)

(print a) ; => 1
(print b) ; => 2
(print c) ; => [3 4 5]

Macros

Fennel is a lisp language which means it supports macros, if you ever read anything about lisp you see people talking about them as the holy grail

And indeed, macros are a powerful way to metaprogram and make languages easier to read. Macros are kinda normal functions but they get executed at compile time and output code.

With macros, you can extend the fennel language and create domain-specific function, for example, for Neovim, you could create a macro to set a vim option, like:

(set! tabstop 2)
; Or to set keymaps
(map! [:n] "-" (fn [] (print "Opened")) "Open parent directory")

This whole functionality requires an entire blog post, but you can read the extensive fennel macros documentation

Picking a transpiler

Now that I convinced you to use Fennel, let’s see how we can start configuring Neovim with it.

First, you need to find a way to compile the fennel code into lua code, there are many options for this:

I picked tangerine since it’s quite simple to setup and has a nice way to preview the compiled lua code which is very useful while you’re learning fennel.

It also comes with an optional package called hibiscus with useful macros like set! and map!

Configuring Tangerine and hibiscus with lazy

To start using tangerine.nvim we first need to install it, and since we need to load tangerine before anything else to output our lua files.

This is how I did it:

We also need to put tangerine.nvim in the list of Lazy plugins so it gets updated as a normal plugin

In the end, you will end up with a folder structure like

.
├── fnl
│  ├── theme.fnl
│  ├── main.lua
├── init.fnl // The real init
├── init.lua // Load tangerine

Writing the configuration

This is a part of my real init.fnl

(require :options) ; Normal neovim options
(require :theme) ; Load the theme config
(require :plugins) ; /fnl/plugins.lua This is still a lua file and will load all the plugins with Lazy

And here is a part of my options.fnl file

(import-macros {: set! : set+} :hibiscus.vim)

; ...

;; Indentation
(set! expandtab)
(set! shiftwidth 2)
(set! tabstop 2)

;; Line numbers
(set! number)
(set! relativenumber)
(set! numberwidth 3)
(set! numberwidth 3)

;; Whitespace
(set! list)
(set! listchars {:trail "·" :tab "→ " :nbsp "·"})

;; Insert-mode completion
(set+ :shortmess :c)

; ...

An example of setting mappings:

(import-macros {: map!} :hibiscus.vim)

(local oil (require :oil))

(oil.setup {:default_file_explorer true
            :keymaps {:q :actions.close}
            :view_options {:show_hidden true}})

(map! [:n] "-" oil.open "Open parent directory")

Extra: Improving fennel DX in Neovim

Install a code formatter

We will use fnlfmt to format our fnl code

Install it with your favorite package manager

Using brew

brew install fnlfmt

Install a LSP for fennel

If you’re using language servers for completion and diagnostic then you will be glad to know there’s one for fennel called fennel_language_server

This is my configuration:

require 'lspconfig.configs'.fennel_language_server = {
  default_config = {
    cmd = { 'fennel-language-server' },
    filetypes = { 'fennel' },
    single_file_support = true,
    -- source code resides in directory `fnl/`
    root_dir = lspconfig.util.root_pattern("fnl"),
    settings = {
      fennel = {
        workspace = {
          -- If you are using hotpot.nvim or aniseed,
          -- make the server aware of neovim runtime files.
          library = vim.api.nvim_list_runtime_paths(),
          checkThirdParty = false, -- THIS IS THE IMPORTANT LINE TO ADD
        },
        diagnostics = {
          globals = { 'vim' },
        },
      },
    },
  },
}

lspconfig.fennel_language_server.setup {
  on_attach = function(client, bufnr)
    -- Support formatting with fnlfmt
    vim.keymap.set('n', '<leader>cf', function()
      vim.lsp.buf.format({ async = true })
    end, { noremap = true, silent = true, buffer = bufnr, desc = "Format code" })
  end,
}

Install conjure plugin

Conjure is an amazing plugin that lets you execute pieces of code inside Neovim, this is very useful when working with fennel but it also supports lua, python and many other languages.

To install it using Lazy.nvim:

{
  "Olical/conjure",
  -- [Optional] cmp-conjure for cmp
  dependencies = {
    {
      "PaterJason/cmp-conjure",
    },
  },
  config = function()
    require("conjure.main").main()
    require("conjure.mapping")["on-filetype"]()
  end,
  init = function()
    -- Set configuration options here
    vim.g["conjure#debug"] = true
  end,
},

After you install it, conjure sets some default mapping to the file types it supports:

Read the Conjure documentation for more details

Conclusion

Configuring Neovim using Fennel has been a really enjoyable experience, partly made possible by using Conjure.

I liked writing fennel in Neovim so much that I even wrote my first neovim plugin using it scratch-buffer.nvim.

But not everything has been great so far, here are some of the downsides I noticed so far:

References