ob-julia: Emacs org-mode babel support for Julia
The code for this emacs package is also hosted at GitHub - akirakyle/emacs-ob-julia.
This page contains the literate org-mode documentation and implementation.
The first section is the org exported README.org which can also be found in the git repo.
The remaining sections detail the full implementation of both the emacs-lisp and Julia sides.
These code blocks are tangled from this org-mode file directly into the git repo.
README.org
ob-julia provides org babel support for the Julia Programming Language.
This is my take on ob-julia that attempts to be relatively minimalist and use standard emacs interfaces.
Installation
(use-package ob-julia
:ensure (ob-julia :repo "akirakyle/emacs-ob-julia")
:after (org)
:config
(add-to-list 'org-babel-load-languages '(julia . t))
(org-babel-do-load-languages 'org-babel-load-languages org-babel-load-languages))
Setup
(defgroup ob-julia nil
"Org Babel Julia via per-session Julia processes using netstrings."
:group 'org-babel)
(defcustom ob-julia-executable "julia"
"Julia executable."
:type 'string)
(defcustom ob-julia-executable-arguments '("--project=.")
"List of arguments passed to julia executabel."
:type 'string)
(defvar org-babel-default-header-args:julia
'((:module . "Main") (:results . "both") (:output-dir . "./.ob-julia/")))
Examples
:result {output, value, both}
#+begin_src julia
2π
#+end_src
: 6.283185307179586
#+begin_src julia
println("Hello, Julia σ 👋")
#+end_src
: Hello, Julia σ 👋
#+begin_src julia :results output
println("hi")
1+5
#+end_src
: hi
#+begin_src julia :results value
println("hi")
1+5
#+end_src
: 6
#+begin_src julia :results both
println("hi")
1+5
#+end_src
: hi
: 6
Exception error pane
#+begin_src julia
throw(ErrorException("I've done a bad thing");)
#+end_src
Sessions
#+begin_src julia :results none :session ses1
x = 12345
#+end_src
#+begin_src julia :results none :session ses2
x = 67890
#+end_src
#+begin_src julia :results value :session ses1
x
#+end_src
: 12345
:var header arguments
#+begin_src julia :var r = 4
"the area of a circle with radius of $r is $(π * r ^ 2)."
#+end_src
: "the area of a circle with radius of 4 is 50.26548245743669."
Images
#+begin_src julia :dir ../figs/ob-julia
using Pkg
Pkg.status()
#+end_src
: Status `~/data/www/akirakyle.com/figs/ob-julia/Project.toml`
: [13f3f980] CairoMakie v0.15.8
#+begin_src julia :results value file link :dir ../figs/ob-julia :output-dir ../figs/ob-julia
using CairoMakie
CairoMakie.activate!(type = "svg", pt_per_unit=2)
xs=collect(-2π:0.1:2π)
lines(xs, sin.(xs))
#+end_src
file:../figs/ob-julia/sin-plot.svg
#+begin_src julia :results value file link :file ../figs/ob-julia/exp-plot.svg
using Pkg
Pkg.activate("../figs/ob-julia")
using CairoMakie
CairoMakie.activate!(type = "svg", pt_per_unit=2)
xs=collect(-2π:0.1:2π)
lines(xs, exp.(xs))
#+end_src
file:../figs/ob-julia/exp-plot.svg
For the record here’s what they look like when exported to html now:
using Pkg
Pkg.status()
Status `~/data/www/akirakyle.com/figs/ob-julia/Project.toml` [13f3f980] CairoMakie v0.15.8
using CairoMakie
CairoMakie.activate!(type = "svg", pt_per_unit=2)
xs=collect(-2π:0.1:2π)
lines(xs, sin.(xs))
using Pkg
Pkg.activate("../figs/ob-julia")
using CairoMakie
CairoMakie.activate!(type = "svg", pt_per_unit=2)
xs=collect(-2π:0.1:2π)
lines(xs, exp.(xs))
Feature comparison
All the ob-julia implementations I could find:
- upstream version: org-mode/lisp/ob-julia.el at main · bzg/org-mode · GitHub
- Depends on ESS - Emacs Speaks Statistics
- the original?: GitHub - nico202/ob-julia: Org Mode babel support for Julia
- using vterm: GitHub - shg/ob-julia-vterm.el: Org-babel support for Julia code blocks using julia-vterm
- using emacs-snail: GitHub - gcv/julia-snail: An Emacs development environment for Julia
- a notable fork: GitHub - karthink/ob-julia: Org Mode babel support for Julia
- https://blog.tecosaur.com/tmio/2021-05-31-async.html
I took a good look at all the ob-julia implementations I could find before embarking on creating this take on ob-julia.
I had hopped one of them could be fix/adapted to my workflows, however I found them all to have unnecessarily complicated logic around their rpc protocol implementation.
I think it’s good to view org-babel as essentially a simple rpc protocol for executing arbitrary code of other languages from elisp.
So the main distinction of this package is the use of netstrings for rather than a unique message terminator to facilitate this rpc protocol.
While a unique message terminator works okay in practice and helps facilitate streaming results back in principle, I think it unnecessarily complicates the implementation and in practice emacs does not handle rapid async filter functions causing buffer modifications always so elegantly.
Implementing netstrings is easy
function read_netstring(io::IO)
len_str = String(readuntil(io, UInt8(':')))
n = parse(Int, len_str)
n >=0 || error("bad netstring: invalid length of %d", n)
data = String(read(io, n))
newline = read(io, UInt8)
newline == UInt8('\n') || error("bad netstring: invalid termination of %s", newline)
return data
end
function write_netstring(io::IO, data::AbstractVector{UInt8})
write(io, string(length(data)))
write(io, UInt8(':'))
write(io, data)
write(io, UInt8('\n'))
end
write_netstring(io::IO, s::AbstractString) = write_netstring(io, Vector{UInt8}(codeunits(s)))
Use ’\n’ instead of ’,’ to ensure flushes happen on the Julia side
(defun ob-julia--netstring-encode (s)
(let* ((bytes (encode-coding-string (or s "") 'utf-8 t)))
(format "%d:%s\n" (string-bytes bytes) bytes)))
(defun ob-julia--netstring-pop ()
(goto-char (point-min))
(when-let ((colon (save-excursion (search-forward ":" nil t))))
(let* ((len-str (buffer-substring (point-min) (1- colon)))
(len (string-to-number len-str)))
(unless (and (integerp len) (>= len 0))
(error "bad netstring: invalid length of %d" len))
(let ((need (+ len 1))
(start colon))
(when (<= (+ start need) (1+ (buffer-size)))
(let* ((payload (buffer-substring start (+ start len)))
(newline (buffer-substring (+ start len) (+ start len 1))))
(unless (string= newline "\n")
(error "bad netstring: invalid termination of %s" newline))
(delete-region (point-min) (+ start len 1))
payload))))))
Process mgmt can be tricky
(setq ob-julia--process-prefix "ob-julia:")
(defun ob-julia--process-name (session)
(format "%s%s" ob-julia--process-prefix session))
(defun ob-julia--process-sentinel (p _event)
;;(message "ob-julia--process-sentinel (proc: %s) (event: %s)" p _event)
(let* ((session (process-get p 'ob-julia-session))
(buf (process-buffer p)))
(when (buffer-live-p buf)
(kill-buffer buf))))
(defun ob-julia--install-filter-and-sentinel (proc session)
(set-process-coding-system proc 'binary 'binary)
(set-process-filter proc #'ob-julia--process-filter)
(set-process-sentinel proc #'ob-julia--process-sentinel)
(set-process-coding-system proc 'raw-text 'raw-text)
(process-put proc 'ob-julia-session session)
(process-put proc 'ob-julia-queue (make-queue))
proc)
(defun ob-julia--start (session)
(let* ((process-connection-type nil)
(tramp-pipe-stty-settings "")
(tramp-direct-async-process t)
(proc-name (ob-julia--process-name session))
(proc-args (append ob-julia-executable-arguments
(list "-e" ob-julia--julia-rpc-program)))
(proc (apply #'start-file-process proc-name (format "*%s*" proc-name)
ob-julia-executable proc-args)))
(ob-julia--install-filter-and-sentinel proc session)))
(defun ob-julia-session-list ()
(let ((filter-ob-julia (lambda (name)
(string-prefix-p ob-julia--process-prefix name)))
(strip-ob-julia (lambda (name)
(string-remove-prefix ob-julia--process-prefix name)))
(procs (mapcar #'process-name (process-list))))
(funcall 'mapcar strip-ob-julia (funcall 'seq-filter filter-ob-julia procs))))
(defun ob-julia-interrupt-session (session)
"Try to interrupt a Julia session."
(interactive
(list (completing-read "Interrupt session: " (ob-julia-session-list) nil t)))
(let* ((proc (get-process (ob-julia--process-name session))))
(interrupt-process proc)))
(defun ob-julia-stop-session (session)
"Stop a Julia session."
(interactive
(list (completing-read "Stop session: " (ob-julia-session-list) nil t)))
(let* ((proc (get-process (ob-julia--process-name session))))
(if (process-live-p proc)
(delete-process proc)
(message "No process selected or invalid process name."))))
(process-connection-type nil) uses a pipe to avoid command-type ansi escape sequences coming from Julia
TRAMP is a bit of a mess but at least it mostly seems to work so long as one gets the settings right
rpc is a bit repetitive
julia rpc handlers
const NARGS = 2
function handle_eval(code::String, iolimit::String)
mod = Main
mkio(io) = IOContext(io, :limit => iolimit == "true" ? true : false,
:module => mod, :color => true)
outio_ = Pipe()
errio_ = Pipe()
resio_ = IOBuffer()
excio_ = IOBuffer()
outio = mkio(outio_)
errio = mkio(errio_)
resio = mkio(resio_)
excio = mkio(excio_)
mime = "text/plain"
redirect_stdio(stdout=outio, stderr=errio, stdin=devnull) do
try
result = Base.include_string(mod, code)
mime = Core.eval(mod, :(ObJulia.format_result($result, $resio)))
catch err
Base.display_error(excio, err, catch_abbreviated_backtrace(excio))
end
println(stdout)
println(stderr)
end
stdout_text = String(readavailable(outio_))[1:end-1]
stderr_text = String(readavailable(errio_))[1:end-1]
except_text = String(take!(excio_))
result_text = String(take!(resio_))
close(outio_)
close(errio_)
return (stdout_text, stderr_text, except_text, result_text, mime)
end
function handle_rpc(msg::String)::String
payload = IOBuffer(msg)
args = [read_netstring(payload) for _=1:NARGS]
stdout, stderr, except, result, mime = handle_eval(args...)
io = IOBuffer()
write_netstring(io, stdout)
write_netstring(io, stderr)
write_netstring(io, except)
write_netstring(io, result)
write_netstring(io, mime)
return String(take!(io))
end
function handle_stream(ioin::IO, ioout::IO)
while isopen(ioin) && !eof(ioin)
write_netstring(ioout, handle_rpc(read_netstring(ioin)))
flush(ioout)
end
end
NARGS should be set to the number of arguments of handle_eval
Pipe() is necessary since IOBuffer() cannot be used with stdio here:
- see Redirect output to an IOBuffer · Issue #12711 · JuliaLang/julia · GitHub
- instead of Pipe() could use: GitHub - JuliaIO/Suppressor.jl: Julia macros for suppressing and/or capturing output (STDOUT), warnings (STDERR) or both streams at the same time.
- see also setoutputstream(AsyncStream) by WestleyArgentum · Pull Request #3044 · JuliaLang/julia · GitHub
The println(stdout) and println(stderr) calls ensure that at least one character is in pipe, otherwise readavailable will hang.
TODO fix scoping and eval Module
To avoid this error when setting a variable inside a for loop that was defined outside…
: ┌ Warning: Assignment to `pket` in soft scope is ambiguous because a global variable by the same name exists: `pket` will be treated as a new local. Disambiguate by using `local pket` to suppress this warning or `global pket` to assign to the existing global variable.
: └ @ string:13
Which goes away with a let...end block around it
TODO Implement streaming
Streaming results makes sense only for stdio since results and except aren’t available until execution has finished.
So it might make sense to basically have “two channels”, one for the rpc exec and results/recept messages that still works to queue all the requests.
Then another channel that is just for streaming stdio back and inserting it in results block as it comes in…
This can work by just checking whatever is at head of queue and putting it there…
And maybe I should just use stdout for one and stderr for the other since I have them available and then I can just wire up the redirect to go directly out the pipe instead of writing to an empty Pipe() followed by readavailable.
Ok actually I only need one channel for this if I move to “status” messages and I still need the redirects for stderr.
This would also be a good time to fix the :results value file link working with output as well…
TODO implement help (docs) and Pkgs modes
using more “status” messages
elisp babel exec side
(defun ob-julia--process-filter (proc str)
;;(message "ob-julia--process-filter from proc %s:" proc) (print str)
(with-current-buffer (process-buffer proc)
(goto-char (point-max))
(insert str)
(when-let ((payload (ob-julia--netstring-pop)))
(insert payload)
(let* ((stdout (decode-coding-string (ob-julia--netstring-pop) 'utf-8))
(stderr (decode-coding-string (ob-julia--netstring-pop) 'utf-8))
(except (decode-coding-string (ob-julia--netstring-pop) 'utf-8))
(result (decode-coding-string (ob-julia--netstring-pop) 'utf-8))
(mime (decode-coding-string (ob-julia--netstring-pop) 'utf-8))
(req (queue-dequeue (process-get proc 'ob-julia-queue)))
(buf (plist-get req :buf))
(src-begin (plist-get req :src-begin)))
(ob-julia--finalize buf src-begin stdout stderr result except mime)))))
(defun org-babel-execute:julia (body params)
(let* ((src-begin (make-marker))
(session (cdr (assq :session params)))
(iolimit (equal (cdr (assq :iolimit params)) "yes"))
(var-lines (org-babel-variable-assignments:julia params))
(code (if var-lines
(concat (mapconcat #'identity var-lines "\n")
(if (string-match-p "\\`[ \t\n]*\\'" (or body "")) "" "\n")
body)
body))
(msg (ob-julia--netstring-encode
(concat (ob-julia--netstring-encode code)
(ob-julia--netstring-encode iolimit))))
(session (cdr (assq :session params)))
(proc (or (get-process (ob-julia--process-name session))
(ob-julia--start session))))
(set-marker src-begin (org-element-property :begin (org-element-context)))
(queue-enqueue (process-get proc 'ob-julia-queue)
(list :buf (current-buffer) :src-begin src-begin))
(process-send-string proc msg)
(format "Executing in session %s..." session)))
Sometimes the UI is the hardest
julia and elisp formatting results and errors for org-babel-insert-result
function format_result(result, io)
for mime in ("image/svg+xml", "image/png")
if showable(mime, result)
show(io, mime, result)
return mime
end
end
isnothing(result) || show(io, "text/plain", result)
return "text/plain"
end
See file:///Users/akyle/data/code/org/lisp/ob-core.el#org8ae1454
(defun ob-julia--mime-to-file-ext (mime)
(cond ((string= mime "image/svg+xml") ".svg")
((string= mime "image/png") ".png")
(t (error "unhandled mime type"))))
(defun ob-julia--temp-file (mime info)
(let* ((params (nth 2 info))
(name (nth 4 info))
(session (cdr (assq :session params)))
(file-ext (ob-julia--mime-to-file-ext mime))
(dir (cdr (assq :output-dir params)))
(fname (if name name (make-temp-name session)))
(fname (concat fname file-ext)))
(concat (file-name-as-directory dir) fname)))
(defun ob-julia--handle-result-mime (result mime info)
(if (string= mime "text/plain")
result
(let* ((file (cdr (assq :file (nth 2 info))))
(file (if file file (ob-julia--temp-file mime info))))
(write-region result nil file)
file)))
(defun ob-julia--show-error-pane (stderr)
(let* ((buf (get-buffer-create "*ob-julia backtrace*")))
(with-current-buffer buf
(let ((inhibit-read-only t))
(erase-buffer)
(insert (or stderr ""))
(ansi-color-apply-on-region (point-min) (point-max))
(compilation-mode)))
(display-buffer buf)))
(defun ob-julia--apply-ansi-in-last-result ()
(when-let ((beg (org-babel-where-is-src-block-result nil nil)))
(save-excursion
(goto-char beg)
(when (looking-at org-babel-result-regexp)
(let ((end (org-babel-result-end))
(ansi-color-context-region nil))
(ansi-color-apply-on-region beg end))))))
(defun ob-julia--finalize (src-buf src-begin stdout stderr result except mime)
(with-current-buffer src-buf
(save-excursion
(goto-char src-begin)
(let* ((info (org-babel-get-src-block-info))
(params (nth 2 info))
(rparams (cdr (assq :result-params params)))
(session (cdr (assq :session params)))
(result (ob-julia--handle-result-mime result mime info))
(result
(org-babel-result-cond rparams
result
(org-babel-reassemble-table
result
(org-babel-pick-name (cdr (assq :colname-names params))
(cdr (assq :colnames params)))
(org-babel-pick-name (cdr (assq :rowname-names params))
(cdr (assq :rownames params))))))
(result (cond
((member "output" rparams) (concat stderr stdout))
((member "value" rparams) result)
((member "both" rparams) (concat stderr stdout result)))))
(unless (or (member "none" rparams) (string-empty-p except))
(if (member "silent" rparams)
(message (ansi-color-apply except))
(ob-julia--show-error-pane except)))
(when (equal session "none")
(ob-julia-stop-session "none"))
(unless (member "none" rparams)
(org-babel-insert-result result rparams info)
(ob-julia--apply-ansi-in-last-result)
(org-link-preview-refresh))))))
catch_abbreviated_backtrace(_) = catch_backtrace()[1:end-27]
Currently an offset of 27 starts the stacktrace at include_string.
The following code helps to determine the correct offset value to use.
If the callstack to get to includestring in handleeval changes then the bt offset potentially needs to be updated.
See also:
function catch_abbreviated_backtrace(io)
bt_caught = catch_backtrace()
bt_here = backtrace()
println(io, "bt_caught length: ", length(bt_caught))
Base.display_error(io, ErrorException, bt_caught)
println(io, "bt_here length: ", length(bt_here))
Base.display_error(io, ErrorException, bt_here)
N = length(bt_caught)
offset = length(bt_here) - N
while N > 1
if bt_caught[N] ≢ bt_here[N + offset]
break
end
N = N - 1
end
println(io, "N: ", N)
return bt_caught[1:N]
end
TODO fix org-babel-insert-result so that ALL headers are handled in it
currently some processing like :results none happens outside it which makes async code always have to do more to be correct.
See
TODO subtype AbstractDisplay?
Not sure if I should use this pattern here
struct ObJuliaDisplay <: Base.AbstractDisplay
io::IO
end
- I/O and Network · The Julia Language
- For other examples of using
Base.Multimedia.pushdisplaysee- Pluto.jl/src/runner/PlutoRunner/src/display/mime dance.jl at main · JuliaPluto/Pluto.jl · GitHub
- Pluto.jl/src/runner/PlutoRunner/src/io/stdout.jl at main · JuliaPluto/Pluto.jl · GitHub
- IJulia.jl/src/inline.jl at master · JuliaLang/IJulia.jl · GitHub
- Makie.jl/Makie/src/display.jl at master · MakieOrg/Makie.jl · GitHub
- For other examples of using
TODO upstream this patch to get both recognized as a results parameter
From 1437ead0b5059755cede833b05c30d9b0008ea95 Mon Sep 17 00:00:00 2001
From: Akira Kyle <akira@akirakyle.com>
Date: Thu, 15 Jan 2026 00:07:45 -0700
Subject: [PATCH] Add both to results babel header
---
lisp/ob-core.el | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lisp/ob-core.el b/lisp/ob-core.el
index f72d6a6ee..6269d01bc 100644
--- a/lisp/ob-core.el
+++ b/lisp/ob-core.el
@@ -437,7 +437,7 @@ then run `org-babel-switch-to-session'."
(results . ((file list vector table scalar verbatim)
(raw html latex org code pp drawer link graphics)
(replace silent none discard append prepend)
- (output value)))
+ (output value both)))
(rownames . ((no yes)))
(sep . :any)
(session . :any)
--
2.52.0
babel variable assignments
(defun ob-julia--escape-string (s)
(replace-regexp-in-string "\"" "\\\\\"" s))
(defun ob-julia--value-to-julia (value)
(cond
((listp value) (format "\"%s\"" value))
((numberp value) value)
((stringp value) (or (org-babel--string-to-number value)
(concat "\"" (ob-julia--escape-string value) "\"")))
((symbolp value) (ob-julia--escape-string (symbol-name value)))
(t value)))
(defun org-babel-variable-assignments:julia (params)
(mapcar
(lambda (pair)
(format "%s = %s" (car pair) (ob-julia--value-to-julia (cdr pair))))
(org-babel--get-vars params)))
TODO fix async evaluation with :var headers to named babel blocks
12.345
12.345
r
Executing in session radius...
"the area of a circle with radius of $r is $(π * r ^ 2)."
Putting it all together
the julia side
module ObJulia
<<jl-netstrings>>
<<jl-format-results>>
<<jl-backtrace>>
<<jl-rpc>>
handle_stream(stdin, stdout)
throw(EOFError())
end
A cool babel trick
(let* ((info (org-element-map (org-element-parse-buffer) org-element-all-elements
(lambda (element)
(when (string= (org-element-property :name element) name)
(org-babel-get-src-block-info t element)))
nil t))
(coderef (nth 6 info))
(expand (org-babel-expand-noweb-references info))
(result (if (not coderef) expand
(replace-regexp-in-string
(org-src-coderef-regexp coderef) "" expand nil nil 1))))
(format "\"%s\"" (replace-regexp-in-string "\\\"" "\\\\\"" result))) ;; escape quotes
To fix the striping of coderefs I copied from file:///Users/akyle/data/code/org/lisp/ob-core.el#orge166ab8
the elisp side
;; ob-julia.el -*- lexical-binding: t -*-
<<license-header>>
Yes we always want lexical binding when will you stop asking?
(require 'org)
(require 'org-element)
(require 'ob-core)
(require 'ansi-color)
(require 'queue)
Now ask yourself, which of these ought to be fundamental enough to be part of the “elisp standard library”, and which of these ought to be part of the “core of emacs”, and which ought to just be external packages? Now guess which one(s) of these I have to install through a package manager, and which one(s) I keep carrying patches to.
<<el-custom>>
(setq ob-julia--julia-rpc-program
<<blk-to-elisp-str("all-the-jl")>>
)
<<el-helpers>>
<<el-netstrings>>
<<el-proc-mgmt>>
<<el-process-rpc>>
<<el-rpc>>
And finally after requiring, we can provide.
(provide 'ob-julia)
the license header
;; Copyright (C) 2026 Akira Kyle
;; Author: Akira Kyle <akira@akirakyle.com>
;; URL: https://github.com/akirakyle/emacs-ob-julia
;; Version: 0.1
;; Package-Requires: ((org) (queue))
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; See README.org
;;; Code:
A test julia Project.toml
[deps]
CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"