Fennel wiki: Lite

Lite is a full-featured extensible text editor is written in around 4.4kloc of Lua and 1kloc of C. It's built with a slim core and a flexible API so that users can extend it and add features by writing their own Lua code. Like most programs that embed Lua, it's easy to extend using Fennel too!

First we want to install the Fennel plugin for Lite so we can at least get syntax highlighting. Clone the lite-plugins repo and copy plugins/language_fennel.lua to the data/plugins directory. This isn't strictly necessary, but it sure makes things a lot nicer.

One of the main shortcomings of Lite is that it doesn't ship with any facility for reloading your config. Let's see how tricky this would be to add using Fennel! We start by opening up the data/user/init.lua file which is loaded by Lite but is intended to include user config.

We can put just the minimum amount of code here needed to bootstrap the real config in a separate init.fnl file after copying fennel.lua into the data/plugins directory.

local fennel = require("plugins.fennel")
table.insert(package.loaders or package.searchers, fennel.searcher)
require("data.user.config")

Now we can put all the actual config in data/user/config.fnl:

(local command (require :core.command))
(local fennel (require :plugins.fennel))

(command.add nil {:user:reinit #(fennel.dofile "data/user/config.fnl")})

The nil here means that the commands we're adding are always available. We could instead provide a predicate function to determine whether the command is applicable in a given context. Unfortunately Lite doesn't really have a concept of first-class modes; predicates are the only way to group commands together, and they're not as useful as a table would be. Oh well.

Anyway now we can access the "User: Reinit" command by pressing ctrl-shift-p. (Unfortunately we have to quit and launch Lite again still at this point before the command is available.) Let's try it!

data/core/command.lua:19: command already exists: user:reinit

Oh dear. It appears Lite really doesn't like reloading, does it. Well, it's all just Lua; let's take a look at the code where that error comes from:

function command.add(predicate, map)
  predicate = predicate or always_true
  if type(predicate) == "string" then
    predicate = require(predicate)
  end
  if type(predicate) == "table" then
    local class = predicate
    predicate = function() return core.active_view:is(class) end
  end
  for name, fn in pairs(map) do
    assert(not command.map[name], "command already exists: " .. name)
    command.map[name] = { predicate = predicate, perform = fn }
  end
end

That assert near the end is the thing that's causing the trouble. Apparently it wants to make sure the command being added doesn't already exist! Well that's quite silly; it completely defeats the purpose of reloading. But we can work around it from our Fennel code since the table containing the commands is exposed to us:

(local command (require :core.command))
(local fennel (require :fennel))

(tset command.map :user:reinit k nil)
(command.add nil {:user:reinit #(fennel.dofile "data/user/config.fnl")})

Now that's more like it! But it's still quite limited; it only lets us reload a single file. What we really want is a REPL, a Read Eval Print Loop. Unfortunately a REPL doesn't really fit well into Lite's model of how buffers and files work, but ... could we settle for a REP instead? A command which reads, evaluates, and prints, well that's a decent approximation. Let's give it a try.

We're going to use the power of coroutines to take Fennel's standard REPL and tie it into our Lite REP command. Let's create a coroutine that wraps the fennel.repl function:

(local repl (coroutine.create fennel.repl))

Once we have our coroutine, we can start it by using coroutine.resume and passing in the options we'll use to tie into Lite. (The arguments to the first call to coroutine.resume are the same arguments we would have passed to fennel.repl directly if we had run it outside a coroutine.) Three things are needed in the options table: a readChunk function to give us strings of input (in a normal repl this is basically just io.read), onValues to display normal values and onError to display errors.

Lite provides the core.log and core.error functions for these latter two which just display the string to the bottom message area. Our readChunk function is just coroutine.yield which we'll explain soon:

(local {: log : error &as core} (require :core))

(coroutine.resume repl {:readChunk coroutine.yield
                        :onValues #(log (table.concat $...))
                        :onError #(error (table.concat $...))})

So what exactly do we have here? Well, the standard Fennel repl (the same thing you get when you run fennel in your shell) is now running inside a coroutine such that it gets its input from coroutine.yield and writes its output to Lite's log. We started up the repl, and once it got going, it went to read some input. Since its input function is coroutine.yield, that meant that it immediately yielded back to the original calling code without doing anything. However, just because it yielded doesn't mean it returned. We can go back to the point at which it yielded and give it some data so that it can continue.

So if we were to simply run something like this, it would get evaluated by the repl exactly as if we had typed it in during a repl session in the shell:

(coroutine.resume repl "{:math (+ 1 2 (* 3 4))}")

But obviously we don't want canned input, we want to accept input from the user. In order to do that we need to tie into another part of Lite's UI:

(fn handle [input]
  (coroutine.resume repl (.. input "\n")))

(fn rep []
  (core.command_view:enter :eval handle))

(tset command.map :user:rep nil)
(command.add nil {:user:rep rep})

The core.command_view:enter function opens up the message area in the bottom for text input. It takes a prompt string and a handler function which is given the input as its argument. Then we add it as a command so it can be invoked using ctrl-shift-p.

Give it a try! It should show you the results of any expression in the message area.

At this point we no longer need the original user:reinit command. That's because the repl has built-in reloading capabilities--this isn't just a barebones repl; this is the same standard repl that ships with Fennel, and that means it has access to commands like ,reload user.data.config which will allow us to do reloads for any module in the whole system, not just the initial config.

That's just scratching the surface of what you can do when extending Lite with Fennel; there's plenty more you can do.