I gave up on Emacs but took org-babel with me to Neovim

Babel Fish

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:

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