I gave up on Emacs but took org-babel with me to Neovim
![]()
The Babel Fish is small, yellow, and simultaneously translates from one spoken language to another.
– The Hitchhiker's Guide to the Galaxy, Douglas Adams
I watch Tsoding on YouTube sometimes and he uses Emacs. Something about watching him work so smoothly made me curious and I tried to set it up and gave up almost immediately. But before I left I picked up concepts of literate programming and org-babel.
The Emacs community has this thing where you write your configuration inside a document where you explain every decision, and the actual config gets extracted from the code blocks automatically.
I thought that was cool. Not cool enough to switch to Emacs, but cool enough to steal :D
My Problem
I often copy code from other people's configs (confession). You find a nice setup, paste it into your init.lua, move on. After a while you have 1337 lines of Lua and you don't know what half of it does. I would open my own config and not recognize things. Settings I couldn't explain. Plugins I forgot I installed.
Also every time I set up a new machine I would clone my dotfiles, open Neovim, watch it break because I forgot to install ripgrep or clangd, and then spend time figuring out what my config actually needs.
What I Did
Now my whole Neovim config lives in README.md. A script called tangle.lua (~150 lines) reads it, pulls out all the ```lua code blocks, and writes them into init.lua. Neovim loads init.lua normally. It doesn't know anything changed.
I never edit init.lua itself, just the README. Every plugin has a paragraph next to it explaining what it does and why I needed it. GitHub renders it nicely, so the repo landing page is the documentation.
Full repo: https://github.com/anajobava/neovim-config
How Tangling Works
The tangler is a state machine that walks the README line by line. Lua's pattern matching can't match across newlines, so you can't just regex out the fenced blocks from the whole file. Instead it tracks state: am I inside a block or outside one?
The logic looks like this:
- See
```lua? Start collecting lines into a Lua block. - See
```lua notangle? Skip it. This is the escape hatch for code you want to show in the docs but not include ininit.lua(display-only). - See
```sh install? Start collecting into a shell block (more on this later). - See
```(closing fence)? Save the block, stop collecting. - See
## Some Heading? Remember the heading name and line number. This gets attached to the next block for labeling and the source map. - Anything else while collecting? Append it to the current block.
That's the whole parser. Each collected block knows its content, which section heading it belongs to, and where that heading is in the README. The Lua blocks get concatenated in order into init.lua. Each block gets a comment label above it like -- [Editor Options] so you can still orient yourself in the generated file.
The Safety Check
This is the part that matters most in practice. Before replacing your working init.lua, the tangler runs loadfile() on the temp file:
local ok, err = loadfile(tmp)
if not ok then
vim.fn.delete(tmp)
vim.fn.delete(maptmp)
vim.fn.delete(depstmp)
notify("Tangle FAILED - fix the error below, then save again:\n" .. err,
vim.log.levels.ERROR)
return
end
If there's a syntax error in any of your code blocks, it refuses to overwrite. You get a notification saying what's broken, and the old config stays untouched. You can't break your editor by saving a typo.
The whole write process uses temp files. It writes init.lua.tmp, validates it, then does vim.fn.rename() to swap it over the real file. If the rename happens, it's atomic. If validation fails, the temps get deleted and nothing changes.
Source Map and TangleJump
The tangler tracks line numbers as it writes init.lua and outputs a source map file (init.lua.map). It's a Lua table that maps ranges:
return {
{ init_start= 4, init_end= 10, readme_line= 64, section="Auto-Tangle on Save" },
{ init_start= 12, init_end= 13, readme_line= 90, section="Leader Key" },
-- ...
}
So if you get an error at line 12 of init.lua, you can look it up and see it came from the "Leader Key" section starting at line 90 of README.md. You could write a small command (call it :TangleJump) that reads this map and jumps you from init.lua to the right spot in the README. I haven't done this yet but the map is there for it.
Auto-Tangle on Save
After the initial bootstrap you don't run the tangler manually. An autocmd catches saves to README.md and re-tangles:
vim.api.nvim_create_autocmd("BufWritePost", {
pattern = "*",
callback = function(args)
local cfg = vim.fn.stdpath("config")
local readme = vim.fn.resolve(vim.fn.fnamemodify(cfg .. "/README.md", ":p"))
if vim.fn.resolve(vim.fn.fnamemodify(args.file, ":p")) == readme then
dofile(cfg .. "/tangle.lua")
end
end,
})
The fnamemodify and resolve calls normalize the path so it works regardless of how you opened the file. This autocmd itself lives in the README as a Lua block. It tangles itself into existence. For the first run you do nvim -l tangle.lua manually once.
Inline Dependencies
This is the other thing the tangler extracts. Blocks marked ```sh install get collected into dependencies.sh. The key thing is that each install block lives right next to the section that needs it. So the Telescope section says "live_grep needs ripgrep" and the install command is right there:
PKG ripgrep
if command -v apt-get &>/dev/null; then
PKG fd-find
else
PKG fd
fi
The LSP section has its own install block for clangd and pylsp. The prerequisites section installs git, curl, gcc, make. Nobody has to scroll to some separate "Dependencies" section at the top and keep it in sync with the rest of the file. The dependency lives next to the code that needs it.
The generated dependencies.sh detects your package manager (apt, pacman, or brew) and runs the right commands. New machine setup becomes three commands:
git clone <repo> ~/.config/nvim
nvim -l ~/.config/nvim/tangle.lua
bash ~/.config/nvim/dependencies.sh
LSP Inside Markdown
The obvious problem I had was that I was editing Lua inside a Markdown file so I had no completions, no diagnostics, nothing. To fix this I found otter.nvim, it detects code blocks, creates hidden buffers for each language, and attaches the language server. So when your cursor is in a ```lua block you get full Lua LSP. Without this the whole workflow would be annoying to use.
I also use render-markdown.nvim to render headings and code block backgrounds directly in the buffer.
What I'd Do Differently
Right now everything tangles into one init.lua. If your config gets big enough you probably want multi-file output, like lua/plugins/telescope.lua and so on. Org-babel lets you specify per-block output files with a :tangle header argument. I don't have that. It would be maybe 20 more lines in the tangler but I haven't needed it yet.
The source map tracks sections but not individual README line numbers per code line. So error messages point you to the right section but not the exact line in the README. Good enough for me so far but not perfect.
loadfile() only catches syntax errors. If your config has a runtime error (like calling a function that doesn't exist), the tangler won't catch it. It will happily write a syntactically valid but broken config. There's not much you can do about this without actually executing the code, which you don't want to do during tangling.
The tangler also doesn't handle nested fences. If you have a code block inside a code block (like documenting the tangler itself), you need to be careful. I haven't hit this in practice but it's an edge case I guess.
Is This for You
Maybe not. If you never forget your config and don't copy from others, definitely not. But I still wanted to share.
Full implementation: tangle.lua