About this Book
I've started using mdBook as a way to get better at writing and force myself to have a better understanding of what I'm working on. Hopefully this will help my future self when I need a reference, or potentially someone else.
In terms of mdBook itself, the best resource I've found is the guide book built with mdBook itself found at https://rust-lang.github.io/mdBook.
Quickstart
Create a book with
cargo install mdbook
mkdir book
cd book
mdbook init
The structure is found in src/SUMMARY.md
and supports limited markdown syntax:
# This title is ignored
[Non-numered top-level section](path.md)
# Unclickable top-level title
- [Numered chapter title](path2.md)
- [Nested sub-chapters](path3.md)
[Non-numered top-level section](path.md)
The contents files support normal markdown.
Deploy to GitHub Pages
Add a .github/workflows/gh-pages.yml
file with the contents:
name: github pages
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- name: Setup mdBook
uses: peaceiris/actions-mdbook@v1
with:
mdbook-version: 'latest'
- run: mdbook build
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./book
Go to https://github.com/{username}/{repo}/settings/pages
and ensure the source branch is gh-pages
.
I had to wait some time for it to deploy.
A small ZSH script to replace autojump
Add this to a ~/.zshrc
and ensure you have fzy installed.
When you type jd
(jd
followed by a space without pressing enter), fzy will pop open and let you change your $PWD
to a previously visited directory. For details, just read the comments in the code snippet.
Note: To change the keybinding, modify the $JUMPDIR_KEYBIND
variable.
JUMPDIR_KEYBIND='jd '
# Setup some data a data file to store visited directories
mkdir -p "$XDG_DATA_HOME/zshrc"
JD_DATA_DIR="$XDG_DATA_HOME/zshrc/chpwd.txt"
touch $JD_DATA_DIR
local tmp=$(mktemp)
cat $JD_DATA_DIR | while read dir ; do [[ -d $dir ]] && echo $dir ; done > $tmp
cat $tmp > $JD_DATA_DIR
# Track visited directories
chpwd_functions+=(on_chpwd)
function on_chpwd {
local tmp=$(mktemp)
{ echo $PWD ; cat $JD_DATA_DIR } | sort | uniq 1> $tmp
cat $tmp > $JD_DATA_DIR
}
# zle widget function
function fzy_jd {
# check if `jd ` was triggered in the middle of another command
# e.g. $ aaaaaaajd
# If so, we manually input the `jd `
if [[ ! -z $BUFFER ]]; then
# Append `jd ` to the prompt
BUFFER=$BUFFER$JUMPDIR_KEYBIND
# move the cursor to the end of the line
zle end-of-line
return 0
fi
# ask the user to select a directory to jump to
local dir=$({ echo $HOME ; cat $JD_DATA_DIR } | fzy)
if [[ -z $dir ]]; then
# no directory was selected, reset the prompt to what it was before
zle reset-prompt
return 0
fi
# Setup the command to change the directory
BUFFER="cd $dir"
# Accepts the cd we setup above
zle accept-line
local ret=$?
# force the prompt to redraw to mimic what would occur with a normal cd
zle reset-prompt
return $ret
}
# define the new widget function
zle -N fzy_jd
# bind the widget function to `jd `
bindkey $JUMPDIR_KEYBIND fzy_jd
# a nicety so that executing just jd will mimic the behaviour of just executing
# cd, that is, change the pwd to $HOME
eval "alias $(echo $JUMPDIR_KEYBIND|xargs)=cd"
Keeping Neovim and Kitty Terminal Colorschemes Consistent and Persistent
Whether it's to give my editor a new coat of paint or to make it easier on the eyes for 3am programming, I change its colours all the time. In editors such as Visual Studio Code or Atom, this is a simple matter of choosing a theme from a fuzzy matched drop-down menu. However, in Neovim we have to edit the init.lua
and restart the editor (or source the init.lua
). On top of this, we then need to update our terminal's colours to match (or at least I like to keep them consistent). This is tedious and nowhere near as clean as VSCode.
This tutorial will create a similar experience to VSCode where you can select a colorscheme using a dropdown fuzzy finder, and this colorscheme will remain consistent in Neovim and Kitty even if you close them and reopen.
Note: We will be using base16 colours but you can swap these out for something else.
We will use a file, $XDG_CONFIG_HOME/.base16_theme
, as the single source of truth for our current colours. It will contain a single line with the name of our current base16 colours. For example,
base16-gruvbox-dark-hard
Kitty (Terminal) Setup
Note: These steps only work in kitty.
- Download base16-kitty to
$HOME
- In a shell startup script (e.g.
~/.zshrc
), addeval "kitty @ set-colors -c $HOME/base16-kitty/colors/$(cat $XDG_CONFIG_HOME/.base16_theme).conf"
. This will set the colours for the current terminal window and for newly created terminal windows.
Neovim Setup
-
Install nvim-base16 plugin. You can use a different collection of colorschemes but this will match the themes in base16-kitty we used above.
-
Create a function to update the colours for our current terminal window and current instance of Neovim. We'll also need to update our
$XDG_CONFIG_HOME/.base16_theme
.
-- this is our single source of truth created above
local base16_theme_fname = vim.fn.expand(vim.env.XDG_CONFIG_HOME..'/.base16_theme')
-- this function is the only way we should be setting our colorscheme
local function set_colorscheme(name)
-- write our colorscheme back to our single source of truth
vim.fn.writefile({name}, base16_theme_fname)
-- set Neovim's colorscheme
vim.cmd('colorscheme '..name)
-- execute `kitty @ set-colors -c <color>` to change terminal window's
-- colors and newly created terminal windows colors
vim.loop.spawn('kitty', {
args = {
'@',
'set-colors',
'-c',
string.format(vim.env.HOME..'/base16-kitty/colors/%s.conf', name)
}
}, nil)
end
- Read
$XDG_CONFIG_HOME/.base16_theme
to determine our current colours to use for Neovim.
set_colorscheme(vim.fn.readfile(base16_theme_fname)[1])
-
Install telescope.nvim. Other fuzzy pickers will work, but this code snippet is for this plugin.
-
Override the
colorschemes
picker intelescope.nvim
to update our terminal colours too
nvim.nnoremap('<leader>c', function()
-- get our base16 colorschemes
local colors = vim.fn.getcompletion('base16', 'color')
-- we're trying to mimic VSCode so we'll use dropdown theme
local theme = require('telescope.themes').get_dropdown()
-- create our picker
require('telescope.pickers').new(theme, {
prompt = 'Change Base16 Colorscheme',
finder = require('telescope.finders').new_table {
results = colors
},
sorter = require('telescope.config').values.generic_sorter(theme),
attach_mappings = function(bufnr)
-- change the colors upon selection
telescope_actions.select_default:replace(function()
set_colorscheme(action_state.get_selected_entry().value)
telescope_actions.close(bufnr)
end)
telescope_action_set.shift_selection:enhance({
-- change the colors upon scrolling
post = function()
set_colorscheme(action_state.get_selected_entry().value)
end
})
return true
end
}):find()
end)
Try it out
Open up Neovim, and hit <leader>c
(or whatever mapping you used) and search for a colorscheme. As you scroll through the available colorschemes Neovim should update and Kitty should remain consistant. If you close then open Neovim, the colorscheme will persist with manually changing anything else. Not only that, but you can close and open Kitty and both Kitty and Neovim will maintain the previously selected colorscheme.
Write Your Own Plugin Manager With A Focus On Ergonomics
Note: This is written with Neovim in mind but the same concepts can be applied to Vim
I've been pretty dissatisfied with the state of plugin managers in Neovim. Almost all of them follow a similar flow of editing your init.lua
/init.vim
, running an install command, and restarting Neovim to have the plugin loaded and ready to use. If we compare that with VSCode, we click a button to install the extension, and it usually just works with the occasional need to restart the editor. It's a lot more streamlined in VSCode and not having to restart the editor or manually edit config files is much smoother. What's worse is this is all functionality that can be mimicked in Neovim with a clever use of :h packages
.
At the end of this article we'll have a small plugin manager (~130 lines of Lua) that will let you install the plugin with a simple :PackAdd https://github.com/RRethy/vim-illuminate
which will make it instantly available without the need for a restart and you won't need to touch your dotfiles. Removing the plugin will be as easy as deleting a line in the manifest (we'll get to this later).
Note: This code will not be put into it's own repo but you can still add it to your own dotfiles for use (I currently use it). I'm hoping that this style of plugin manager pushes new plugin managers to create a more ergonomic experience.
High-Level Overview
As with most plugin managers nowadays, we will build on top of the :help packages
feature. However, unlike other managers, we will not use the start/
directory to store plugins since those are all sourced at startup, instead we will download all plugins into opt/
and execute a :packadd <plugin>
to load it dynamically without restarting Neovim. We will also have a separate file that we'll call a pack manifest which is where our plugins will be listed, this manifest will be read at startup and each plugin will be :packadd!
so it gets sourced, this has the added benefit that deleting a plugin is just deleting a line in the manifest.
Note: Not all plugins can be dynamically loaded, just most of them.
Picking a Name
TLDR: backpack.lua
Picking a name is always tough, should we aim for descriptiveness (like nvim-treesitter-textobjects
) or attempt to confuse the user (like wildfire.vim
or vim-hexokinase
), that is the question. Personally, I prefer names with personalities which are at least somewhat indicative of their functionality. A tried a true method for this would be to take a word that is directly related (like pack
since we're using :h packages
) and search for superstrings of that word which also make sense (https://www.thefreedictionary.com/words-containing-pack). We'll use backpack.lua
since it sounds good enough.
Entry Point
Let's create a file lua/backpack.lua
(in our dotfiles) which will hold all of our code.
The directory for our plugins will be vim.fn.stdpath('data')..'/site/pack/backpack/opt/'
which should be recognized by :h 'packpath'
but if it isn't then add a vim.opt.packpath:append('~/.local/share/nvim/site')
to your init.lua
.
As mentioned above we'll have a manifest to specify what plugins we are using, we'll place this at vim.fn.stdpath('config')..'/packmanifest.lua'
inside our dotfiles. Our manifest is a Lua file that we will dofile()
and looks like this:
use { 'RRethy/nvim-treesitter-textsubjects' }
use {
'RRethy/vim-hexokinase',
post_update = { 'make' }
}
use { 'tpope/vim-fugitive' }
use
will be a global we define specifically for use in the manifest to declare a plugin so it gets loaded and we track it in case it should be updated later on.
We'll also want to declare 3 commands for later use: :PackAdd
to add a plugin, :PackUpdate
to update our plugins, :PackEdit
to view/edit the manifest.
Putting this into code looks like the following:
local M = {}
local opt = vim.fn.stdpath('data')..'/site/pack/backpack/opt/'
local manifest = vim.fn.stdpath('config')..'/packmanifest.lua'
local GITHUB_USERNAME = '<your github username>'
local function to_git_url(author, plugin)
if author == GITHUB_USERNAME then
return string.format('git@github.com:%s/%s.git', author, plugin)
else
return string.format('https://github.com:%s/%s.git', author, plugin)
end
end
function M.setup()
vim.fn.mkdir(opt, 'p') -- make sure opt exists
M.plugins = {}
-- 'use' will be define only for use in the manifest
_G.use = function(opts)
local _, _, author, plugin = string.find(opts[1], '^([^ /]+)/([^ /]+)$')
-- track the plugin so it can be updated later with :PackUpdate
table.insert(M.plugins, {
plugin = plugin,
author = author,
post_update = opts.post_update,
})
-- adds the plugin to the end of :help 'runtimepath'
-- this will be what makes the plugin sourced
-- NOTE: :packadd! is not the same as :packadd
if vim.fn.isdirectory(opt..'/'..plugin) ~= 0 then
vim.cmd('packadd! '..plugin)
else
git_clone(plugin, to_git_url(author, plugin), function()
vim.cmd('packadd! '..plugin)
end)
end
end
if vim.fn.filereadable(manifest) ~= 0 then
dofile(manifest)
end
_G.use = nil
-- these functions will be defined later, you can add a --bar too if you want to chain command usage
vim.cmd [[ command! -nargs=1 PackAdd lua require('rrethy.backpack').pack_add(<f-args>) ]]
vim.cmd [[ command! PackUpdate lua require('rrethy.backpack').pack_update() ]]
vim.cmd [[ command! PackEdit lua require('rrethy.backpack').pack_edit() ]]
end
Then our init.lua
will have:
require('backpack').setup()
:PackAdd
Before we add code to install a plugin, we'll need a few helpers to pull Git repos, clone Git repos, and parse a GitHub URL.
We'll start with parsing a GitHub URL. I'm a big fan of just copy pasting the URL (<cmd>l<cmd>c
on OSX) after a :PackAdd
, it's quite ergonomic compared to looking for installation instructions or copying the exact part of the URL.
local function parse_url(url)
-- regex capture the username and plugin name from the url
local username, plugin = string.match(url, '^https://github.com/([^/]+)/([^/]+)$')
if not username or not plugin then
-- failed to parse, we can spit an error out here
return
end
local git_url
if username == GITHUB_USERNAME then
-- a nicety for plugins that you wrote, prefer ssh over https
git_url = string.format('git@github.com:%s/%s.git', username, plugin)
else
git_url = string.format('https://github.com/%s/%s.git', username, plugin)
end
return git_url, username, plugin
end
Now we'll want functions to clone and pull Git repos which will be used by :PackAdd
and :PackUpdate
local function git_pull(name, on_success)
local dir = opt..name
-- get the branch name, there might be a better way to do this
local branch = vim.fn.system("git -C "..dir.." branch --show-current | tr -d '\n'")
-- use Luv to execute an async `git pull` with a shallow fetch
vim.loop.spawn('git', {
args = { 'pull', 'origin', branch, '--update-shallow', '--ff-only', '--progress', '--rebase=false' },
cwd = dir,
}, vim.schedule_wrap(function(code)
if code == 0 then
on_success(name)
else
echoerr(name..' pulled unsuccessfully')
end
end))
end
local function git_clone(name, git_url, on_success)
-- use Luv to execute an async `git clone` with a shallow clone
vim.loop.spawn('git', {
args = { 'clone', '--depth=1', git_url },
cwd = opt,
}, vim.schedule_wrap(function(code)
if code == 0 then
on_success(name)
else
echoerr(name..' cloned unsuccessfully')
end
end))
end
Now we can write our actual :PackAdd
functionality:
function M.pack_add(url)
local git_url, author, plugin = parse_url(url)
if not git_url then
-- failed to parse url
return
end
-- track the plugin in case of a :PackUpdate later
table.insert(M.plugins, {
plugin = plugin,
author = author,
})
local on_success = function()
-- if successful, try loading the plugin dynamically without restarting Neovim
vim.cmd('packadd '..plugin)
end
if vim.fn.isdirectory(opt..plugin) ~= 0 then
git_pull(plugin, on_success)
else
git_clone(plugin, git_url, on_success)
end
-- automatically add the plugin data to our manifest
vim.fn.system(string.format('echo "use { \'%s/%s\' }" >> %s', author, plugin, manifest))
end
:PackUpdate
Since we were tracking our plugins in M.plugins
, we can now just clone/pull each of them.
function M.pack_update()
for _, data in ipairs(M.plugins) do
local on_success = function(plugin)
vim.cmd('packadd '..plugin)
end
if vim.fn.isdirectory(opt..data.plugin) ~= 0 then
git_pull(data.plugin, on_success)
else
git_clone(data.plugin, git_clone, on_success)
end
end
end
:PackEdit
Since the manifest is just a Lua file, we can just open it up in a new tab. Closing the manifest is as simple as :bw
. While this may seem trivial, IMO it's enough.
function M.pack_edit()
vim.cmd('tabnew')
vim.cmd('edit '..manifest)
end
Post-Update Hooks
We were also tracking post-update hooks which is the only additional feature we'll be adding (see below for other feature that were intentionally omitted). This can take the form of a lua function callback or a table representing shell commands to run in the root of the plugin.
We'll add this in M.pack_update
after we dynamically reload the plugin in the on_success
callback.
if data.post_update then
-- plugin root directory
local dir = opt..'/'..plugin
if type(data.post_update) == 'function' then
-- execute the function and pass it the plugin dir
data.post_update(dir)
elseif type(data.post_update) == 'table' then
-- use Luv to run the shell command in the plugin dir
vim.loop.spawn(data.post_update[1], { args = data.post_update.args, cwd = dir },
vim.schedule_wrap(function(code)
if code ~= 0 then
vim.api.nvim_err_writeln(string.format('Failed to run %s', vim.inspect(data.post_update)))
end
end))
end
end
What We Left Out (And Why)
- Lazy loading on filetype
- This frustrates me to no end, this feature is redundant!!! I've seen far too many
Plug 'vim-ruby/vim-ruby', { 'for': 'ruby' }
, just look at the source code for the plugin, it already lazily loads forruby
oreruby
filetypes. In fact, most plugins lazy load most of their code until it's actually used. The few plugins which don't are typically quite old or if the plugin author doesn't know aboutftplugin/
orautoload/
(probably should look for another plugin in that case). On top of this, I don't think people know that this doesn't restrict the plugin to that filetype, once it gets loaded, it's there for all filetypes.
- This frustrates me to no end, this feature is redundant!!! I've seen far too many
- Lazy loading on command
- Same reason as above, most plugins are already lazy loaded. Before you lazy load on
:Foo
, take a look at it's definition to see if it's just calling out to an autoloaded function which hasn't been sourced, if so, then it's already lazy loaded. If a plugin is doing massive amounts of work at startup then it might be time to look for a better written plugin.
- Same reason as above, most plugins are already lazy loaded. Before you lazy load on
- Lua Rocks support
- This would be nice to have but I hope this becomes part of Neovim rather than forcing plugin managers to add support for it.
- Rigorous Error Handling
- I trimmed it off to reduce the complexity and size, simply writing errors to a log file is an easy way to add it back.
local echoerr = vim.api.nvim_err_writeln
local GITHUB_USERNAME = '<your username here>'
local M = {}
local opt = vim.fn.stdpath('data')..'/site/pack/backpack/opt/'
local manifest = vim.fn.stdpath('config')..'/packmanifest.lua'
local function to_git_url(author, plugin)
if author == GITHUB_USERNAME then
return string.format('git@github.com:%s/%s.git', author, plugin)
else
return string.format('https://github.com:%s/%s.git', author, plugin)
end
end
local function parse_url(url)
local username, plugin = string.match(url, '^https://github.com/([^/]+)/([^/]+)$')
if not username or not plugin then
return
end
local git_url
if username == GITHUB_USERNAME then
git_url = string.format('git@github.com:%s/%s.git', username, plugin)
else
git_url = string.format('https://github.com/%s/%s.git', username, plugin)
end
return git_url, username, plugin
end
local function git_pull(name, on_success)
local dir = opt..name
local branch = vim.fn.system("git -C "..dir.." branch --show-current | tr -d '\n'")
vim.loop.spawn('git', {
args = { 'pull', 'origin', branch, '--update-shallow', '--ff-only', '--progress', '--rebase=false' },
cwd = dir,
}, vim.schedule_wrap(function(code)
if code == 0 then
on_success(name)
else
echoerr(name..' pulled unsuccessfully')
end
end))
end
local function git_clone(name, git_url, on_success)
vim.loop.spawn('git', {
args = { 'clone', '--depth=1', git_url },
cwd = opt,
}, vim.schedule_wrap(function(code)
if code == 0 then
on_success(name)
else
echoerr(name..' cloned unsuccessfully')
end
end))
end
function M.setup()
vim.fn.mkdir(opt, 'p')
M.plugins = {}
_G.use = function(opts)
local _, _, author, plugin = string.find(opts[1], '^([^ /]+)/([^ /]+)$')
table.insert(M.plugins, {
plugin = plugin,
author = author,
post_update = opts.post_update,
})
if vim.fn.isdirectory(opt..'/'..plugin) ~= 0 then
vim.cmd('packadd! '..plugin)
else
git_clone(plugin, to_git_url(author, plugin), function()
vim.cmd('packadd! '..plugin)
end)
end
end
if vim.fn.filereadable(manifest) ~= 0 then
dofile(manifest)
end
_G.use = nil
vim.cmd [[ command! -nargs=1 PackAdd lua require('rrethy.backpack').pack_add(<f-args>) ]]
vim.cmd [[ command! PackUpdate lua require('rrethy.backpack').pack_update() ]]
vim.cmd [[ command! PackEdit lua require('rrethy.backpack').pack_edit() ]]
end
function M.pack_add(url)
local git_url, author, plugin = parse_url(url)
if not git_url then
return
end
table.insert(M.plugins, {
plugin = plugin,
author = author,
})
local on_success = function()
vim.cmd('packadd '..plugin)
vim.cmd('helptags ALL')
end
if vim.fn.isdirectory(opt..plugin) ~= 0 then
git_pull(plugin, on_success)
else
git_clone(plugin, git_url, on_success)
end
vim.fn.system(string.format('echo "use { \'%s/%s\' }" >> %s', author, plugin, manifest))
end
function M.pack_update()
for _, data in ipairs(M.plugins) do
local on_success = function(plugin)
vim.cmd('packadd '..plugin)
if data.post_update then
local dir = opt..'/'..plugin
if type(data.post_update) == 'function' then
data.post_update(dir)
elseif type(data.post_update) == 'table' then
vim.loop.spawn(data.post_update[1], { args = data.post_update.args, cwd = dir },
vim.schedule_wrap(function(code)
if code ~= 0 then
vim.api.nvim_err_writeln(string.format('Failed to run %s', vim.inspect(data.post_update)))
end
end))
end
end
end
if vim.fn.isdirectory(opt..data.plugin) ~= 0 then
git_pull(data.plugin, on_success)
else
git_clone(data.plugin, git_clone, on_success)
end
end
end
function M.pack_edit()
vim.cmd('tabnew')
vim.cmd('edit '..manifest)
end
return M