Execute code blocks in markdown files, capture output, insert results inline. Persistent REPL sessions, cross-block variables, tangle to source files, caching.
Written in Lua. No external dependencies beyond neovim 0.10+.
{
"canaanmckenzie/babel.nvim",
ft = "markdown",
opts = {},
}-- In your lazy.nvim config:
{ dev = { path = "~/lab" } }
-- In your plugin spec:
{
"canaanmckenzie/babel.nvim",
dev = true,
ft = "markdown",
opts = {
confirm_execute = false,
show_execution_time = true,
},
config = function(_, opts)
require("babel").setup(opts)
end,
}Open a markdown file. Write a fenced code block. Press <leader>be or <CR>
inside the block. Output appears below the block in HTML comment markers:
```python
print("hello")
```
<!-- :results -->
```
hello
```
<!-- /results -->
Result markers are HTML comments so they render invisibly in GitHub, rendered markdown viewers, and other tools.
Out of the box:
| Language | Type | Notes |
|---|---|---|
| python | interpreted | python3 |
| bash, sh, zsh | interpreted | via stdin |
| javascript | interpreted | node |
| typescript | interpreted | npx ts-node |
| ruby | interpreted | ruby |
| perl | interpreted | perl |
| lua | nvim runtime | runs inside neovim |
| rust | compiled | rustc, edition 2021 |
| c | compiled | cc |
| c++ | compiled | c++, std=c++17 |
| go | compiled | go run |
| zig | interpreted | zig run |
| nim | compiled | nim compile |
| haskell | interpreted | runghc |
| sql | interpreted | sqlite3 |
Adding a language is one entry in config.languages:
opts = {
languages = {
r = { cmd = "Rscript", ext = "r" },
julia = { cmd = "julia", ext = "jl" },
elixir = { cmd = "elixir", ext = "ex" },
},
}Language types:
"interpreted"(default) -- write code to temp file, run command, capture stdout"compiled"-- compile to binary, run binary, capture stdout"nvim"-- execute Lua inside neovim's runtime, capture print() output
Set stdin = true for languages that should receive code via stdin (shells).
Header arguments go on the code fence info string after the language:
```python :results output :dir /tmp :timeout 5
import os
print(os.getcwd())
```
Multiple levels of header args with increasing priority:
- Plugin defaults (
config.header_args) - Per-language defaults (
config.lang_args) - File-level:
<!-- :header-args: :session yes :dir /tmp --> - Heading-level: same syntax, placed between a
#heading and the block - Block-level: on the fence line itself
| Arg | Values | Default | Effect |
|---|---|---|---|
:results |
output, value, table, raw, verbatim | output | How to capture and format output |
:results |
replace, append, prepend | replace | How to handle existing results |
:results |
silent, none | Suppress result insertion | |
:session |
name or yes |
none | Use persistent REPL session |
:dir |
path | buffer dir | Working directory |
:var |
name=value | Variable injection (see below) | |
:eval |
yes, query, never | yes | Whether to execute |
:cache |
yes, no | no | SHA256-based result caching |
:timeout |
seconds | none | Kill execution after N seconds |
:exports |
code, none | code | none runs but hides results |
:file |
path | Write output to file, insert [[link]] |
|
:post |
block-name | Pipe output through another block | |
:wrap |
src lang, quote, or string |
Wrap results in fence or blockquote | |
:prologue |
code string | Prepend to code before execution | |
:epilogue |
code string | Append to code before execution | |
:cmdline |
args | Extra args passed to interpreter |
| Arg | Values | Default | Effect |
|---|---|---|---|
:tangle |
path, yes, no |
no | Target file for tangling |
:noweb |
yes, no, tangle | no | Expand <<ref>> references |
:comments |
link | Add source-linking comments | |
:shebang |
string | Prepend shebang line | |
:tangle-mode |
octal (e.g. o755) |
Set file permissions | |
:mkdirp |
yes, no | no | Create parent directories |
:padline |
yes, no | yes | Blank line between tangled blocks |
Name a block by placing an HTML comment above it:
<!-- name: fetch-data -->
```python
import json
data = json.loads('{"x": 1}')
print(json.dumps(data))
```
Named blocks can be referenced by :var, :post, noweb (<<fetch-data>>),
and CALL directives.
Pass data between blocks with :var:
<!-- name: count -->
```python
print(42)
```
```python :var n=count
print(n * 2)
```
The second block reads the first block's result. If the first block hasn't been executed yet, it runs automatically.
Literal values work too: :var x=5 :var name="hello".
Multiple variables: :var a=1 :var b=block-name :var c=block-name[0].
Indexing: block-name[0] gets the first element (0-based, matching org-babel).
Row-column: block-name[1,2] gets row 1, column 2 from table output.
Data flows across languages. A Python block's JSON output can be read by a bash block or a Lua block. The serialization handles type conversion automatically (numbers, strings, booleans, tables/arrays/objects).
Persistent REPL sessions keep state between executions:
```python :session
x = 10
```
```python :session
print(x) # prints 10
```
Both blocks share a single Python process. Variables, imports, and definitions persist across executions within the same session.
Named sessions: :session myproject creates a separate session.
Supported session languages: python, bash, sh, zsh, javascript (node), ruby (irb).
Session management:
:BabelSessionList-- show active sessions:BabelSessionClose python:default-- close a specific session:BabelSessionRestart python:default-- restart a session:BabelSessionSave-- save Python session state to disk (requirespip install dill):BabelSessionRestore-- restore a saved Python session- All sessions close automatically when neovim exits
Session save/restore is Python-only. It uses dill.dump_session() to serialize
the full interpreter state (variables, imports, function definitions) to
stdpath("data")/babel-sessions/python_default.pkl. Pass python:name to save
a named session.
Sessions use a sentinel protocol: each execution is wrapped in unique start/end
markers so babel can extract exactly the output for that execution from the
continuous REPL stream. Code runs from temp files via exec(open(...).read())
(Python), source (shell), require (node), or load (ruby) to handle
multi-line code cleanly.
Named blocks connected by :var or :post references form a dependency graph.
babel tracks these edges and can visualize or exploit them.
<!-- name: raw-data -->
```python
print("1,2,3")
```
<!-- name: processed -->
```python :var data=raw-data
nums = [int(x) for x in data.split(",")]
print(sum(nums))
```
:BabelExec processed executes raw-data first (if it has no results), then
processed. All upstream dependencies are resolved in topological order.
:BabelExecuteParallel runs all blocks with DAG-aware dispatch. Blocks with
no mutual dependencies fire concurrently; the next tier starts only after all
blocks in the current tier complete. Unnamed blocks run sequentially afterward.
:BabelGraph shows the graph in a floating window:
tier 0: [o raw-data (python)]
raw-data -> processed
tier 1: [o processed (python)]
:BabelGraph mermaid renders a Mermaid flowchart.
Cycles are detected before execution. If blocks A and B reference each other, babel reports the cycle and refuses to run.
:BabelDiff re-executes the block under the cursor and shows a line-by-line
diff between the old and new output in a floating window. Lines prefixed with
- were removed, + were added. If nothing changed, it says so.
Extract code blocks into source files:
<!-- :header-args: :tangle /tmp/myproject.py -->
<!-- name: imports -->
```python :tangle no
import sys
import os
```
```python :noweb yes
<<imports>>
def main():
print("hello from tangled file")
main()
```
:BabelTangle writes /tmp/myproject.py with the noweb reference expanded.
The imports block has :tangle no so its body only appears where referenced.
Noweb expansion preserves indentation. If <<ref>> is indented by 4 spaces,
every line of the expanded body gets those 4 spaces. Expansion is recursive
with cycle detection.
:comments link adds source-tracking comments to the tangled output:
# [[file:notebook.md::15] [imports]]
import sys
import os
# [imports] ends here:BabelDetangle in a tangled file jumps back to the source block.
Skip re-execution when nothing changed:
```python :cache yes
import time
print(time.time())
```
On first run, the result marker stores a SHA256 hash of the block body, relevant header args, and dependency results:
<!-- :results sha256:abc123... -->
On subsequent runs, if the hash matches, babel skips execution and shows "using cached result". Change the block body or any dependency and the cache invalidates automatically.
Reusable named blocks loaded from a file:
:BabelIngest ~/notes/babel-library.md
This reads all named blocks from that file into an in-memory registry.
Any :var reference or CALL directive can then use those blocks by name,
even if they aren't in the current buffer.
Default library file: ~/.config/nvim/babel-library.md (loaded on setup
if it exists).
Execute a named block inline without a code fence:
<!-- call: greet(name="world") :results output -->
This finds the greet block (in buffer or library), injects name="world"
as a variable, executes it, and inserts results below the comment line.
| Command | Default keymap | What it does |
|---|---|---|
| BabelExecute | <leader>be, <CR> |
Execute block under cursor |
| BabelExecuteBuffer | <leader>bB |
Execute all blocks top to bottom |
| BabelExecuteSubtree | <leader>bs |
Execute blocks under current heading |
| BabelNext | <leader>bn |
Jump to next block |
| BabelPrev | <leader>bp |
Jump to previous block |
| BabelClearResults | <leader>bk |
Remove results for block under cursor |
| BabelClearAllResults | <leader>bK |
Remove all results in buffer |
| BabelTangle | <leader>bt |
Tangle blocks to target files |
| BabelDetangle | Jump from tangled file to source block | |
| BabelInspect | <leader>bi |
Show block metadata in floating window |
| BabelToggleResults | <leader>bv |
Fold/unfold results with extmarks |
| BabelCacheToggle | Toggle :cache yes/:cache no on block |
|
| BabelCancel | Kill running execution in current buffer | |
| BabelBlocks | Telescope picker for blocks | |
| BabelSessionList | <leader>bS |
Show active sessions |
| BabelSessionClose | Close a session (lang:name) |
|
| BabelSessionRestart | Restart a session (lang:name) |
|
| BabelIngest | Load named blocks from file into library | |
| BabelLibraryList | Show loaded library blocks | |
| BabelCall | Execute CALL directive under cursor | |
| BabelGraph | Show dependency graph (or BabelGraph mermaid) |
|
| BabelExec | Execute named block with upstream deps | |
| BabelExecuteParallel | Execute all (DAG-aware parallel) | |
| BabelDiff | Re-execute block, show result diff | |
| BabelSessionSave | Save Python session state (dill) | |
| BabelSessionRestore | Restore Python session state |
The <CR> mapping only triggers inside code blocks. Outside a block, it falls
through to the default Enter behavior.
All keymaps can be changed or disabled in config:
opts = {
keymaps = {
execute = "<leader>be",
execute_alt = "<CR>",
execute_buffer = "<leader>bB",
next_block = "<leader>bn",
prev_block = "<leader>bp",
clear_results = "<leader>bk",
clear_all_results = "<leader>bK",
tangle = "<leader>bt",
inspect = "<leader>bi",
toggle_results = "<leader>bv",
session_list = "<leader>bS",
execute_subtree = "<leader>bs",
},
}Set any keymap to false to disable it.
Full defaults:
require("babel").setup({
-- Default header arguments
header_args = {
session = "none",
results = "replace",
exports = "code",
cache = "no",
noweb = "no",
tangle = "no",
dir = nil,
file = nil,
mkdirp = false,
eval = "yes",
wrap = nil,
prologue = nil,
epilogue = nil,
timeout = nil,
},
-- Per-language default args
lang_args = {
python = { results = "output" },
bash = { results = "output" },
lua = { results = "value" },
},
-- Language dispatch table
languages = {
python = { cmd = "python3", ext = "py" },
bash = { cmd = "bash", ext = "sh", stdin = true },
lua = { type = "nvim" },
rust = { type = "compiled", cmd = "rustc", ext = "rs", flags = { "--edition", "2021" } },
-- ... see config.lua for full list
},
-- Library of Babel
library_file = nil, -- defaults to stdpath("config") .. "/babel-library.md"
-- Result markers (HTML comments, invisible in rendered markdown)
result_start = "<!-- :results -->",
result_end = "<!-- /results -->",
-- UI
confirm_execute = false,
show_execution_time = true,
-- Keymaps (see above)
keymaps = { ... },
})The gutter shows execution state:
✓(DiagnosticOk) -- block executed successfully✗(DiagnosticError) -- execution failed⟳(DiagnosticInfo) -- currently running
Virtual text on the fence line shows timing or error status.
:BabelBlocks opens a Telescope picker listing all code blocks in the current
buffer. Each entry shows the block name (if any), language, line number, and
first line of code. Selecting an entry jumps to that block.
Requires telescope.nvim. The picker loads on demand; telescope is not required for any other functionality.
parser.lua is a line-by-line state machine that finds fenced code blocks
(``` and ~~~, 3+ characters). It tracks opening/closing fences, extracts
the info string, collects body lines, and records 0-indexed line ranges. The
parser skips code fences inside result markers to prevent re-parsed output from
appearing as blocks.
Header arguments are parsed from the info string as :key value pairs.
Multiple :var declarations accumulate. Arguments resolve through the
precedence chain described above.
executor.lua dispatches blocks by language type:
- Interpreted: write code to a temp file, run
cmd tempfileviavim.system(), capture stdout/stderr. Shells use stdin instead. - Compiled: write to temp file, compile with
cmd src -o bin, run binary. Both phases are async. - Nvim Lua:
load()the code string, captureprint()output by temporarily replacing the global print function. - Session: send code to a running
vim.fn.jobstart()process viavim.fn.chansend(). Output delimited by sentinel markers.
All execution is async. Results are inserted via vim.schedule() when the
process completes.
:results value wraps the last expression in a print call (language-specific)
to capture its value rather than its stdout.
results.lua formats output and manages the result region in the buffer.
It handles replace/append/prepend modes, table formatting (TSV/CSV to markdown
tables), raw/verbatim/code wrapping, and the :wrap variants.
Results are always between <!-- :results --> and <!-- /results --> markers.
When caching is enabled, the start marker includes the content hash.
variables.lua handles :var resolution. For block references, it reads the
referenced block's result text, parses it (JSON, markdown table, Python repr,
or plain string), optionally indexes into it, then serializes it as a variable
assignment in the target language.
If a referenced block has no results, it gets auto-executed first (with circular dependency detection).
Serialization adapts to the target language: Python gets x = value, shell
gets x='value', Lua gets local x = value, JavaScript gets
const x = value. Tables serialize via JSON with language-native decode.
lua/babel/
init.lua setup, commands, keymaps
config.lua defaults, language table, arg resolution
parser.lua state-machine block extraction
executor.lua async execution dispatch
results.lua output formatting, buffer insertion, diffing
session.lua persistent REPL management, Python save/restore
variables.lua :var resolution and cross-language serialization
tangle.lua tangle, noweb expansion, detangle
cache.lua SHA256 content hashing
library.lua Library of Babel, CALL directives
dag.lua dependency graph, parallel tier execution
telescope.lua Telescope block picker
MIT