Skip to content

canaanmckenzie/babel.nvim

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

babel.nvim

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+.

Install

lazy.nvim

{
  "canaanmckenzie/babel.nvim",
  ft = "markdown",
  opts = {},
}

Local development

-- 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,
}

Usage

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.

Supported languages

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

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:

  1. Plugin defaults (config.header_args)
  2. Per-language defaults (config.lang_args)
  3. File-level: <!-- :header-args: :session yes :dir /tmp -->
  4. Heading-level: same syntax, placed between a # heading and the block
  5. Block-level: on the fence line itself

Execution control

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

Tangle control

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

Named 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.

Variables

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).

Sessions

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 (requires pip 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.

Dependency graph

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.

Result diffing

: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.

Tangle

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.

Caching

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.

Library of Babel

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).

CALL directives

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.

Commands

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.

Configuration

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 = { ... },
})

Signs

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.

Telescope

: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.

How it works

Parser

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

executor.lua dispatches blocks by language type:

  • Interpreted: write code to a temp file, run cmd tempfile via vim.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, capture print() output by temporarily replacing the global print function.
  • Session: send code to a running vim.fn.jobstart() process via vim.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

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

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.

Structure

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

License

MIT

About

org-babel for neovim

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors