Liking cljdoc? Tell your friends :D

ClojureElisp

Clojars Project cljdoc Tests GitHub release License: MIT Emacs

A Clojure dialect that compiles to Emacs Lisp — like ClojureScript targets JavaScript, ClojureElisp targets Emacs.

Write .cljel files using Clojure syntax, compile them to .el files that run natively in Emacs 28.1+.

Installation

Published on Clojars. The Clojars badge above carries the current version, and the Clojars page has ready-to-paste coordinates for deps.edn, Leiningen and Maven.

;; deps.edn
io.github.buddhilw/clojure-elisp {:mvn/version "<latest>"}
;; Leiningen / project.clj
[io.github.buddhilw/clojure-elisp "<latest>"]

The standalone CLI uberjar (clel-<version>.jar) is attached to each GitHub release.

Quick Start

(require '[clojure-elisp.core :as clel])

;; Compile a single form
(clel/emit '(defn greet [name] (str "Hello, " name "!")))
;; => "(defun greet (name)\n  (clel-str \"Hello, \" name \"!\"))"

;; Compile a string of code
(clel/compile-string "(defn inc2 [x] (+ x 2))")
;; => "(defun inc2 (x)\n  (+ x 2))"

;; Compile a .cljel file to .el
(clel/compile-file "src/my_package.cljel" "out/my-package.el")

;; Compile an entire project in dependency order
(clel/compile-project ["src"] "out")

Example

;; my-package.cljel
(ns my.package
  (:require [clojure.string :as str]))

(defn greet [name]
  (let [msg (str "Hello, " name "!")]
    (message msg)))

(defn process-buffer []
  (-> (buffer-string)
      str/upper-case
      insert))

Compiles to:

;;; my-package.el --- -*- lexical-binding: t; -*-
;; Generated by ClojureElisp

(require 'clel)

;;; Code:

(defun my-package-greet (name)
  (let* ((msg (clel-str "Hello, " name "!")))
    (message msg)))

(defun my-package-process-buffer ()
  (insert (upcase (buffer-string))))

(provide 'my-package)
;;; my-package.el ends here

Interactive Development

No build step stands between you and your editor. C-c C-c compiles the form at point and evaluates the resulting Elisp in the Emacs you are using. The function is redefined in the running image, nothing is written to disk, and M-x finds it immediately. It is the elisp REPL loop, with Clojure syntax.

Try it in 30 seconds

examples/bb-demo is a complete ClojureElisp project that runs on Babashka alone. It compiles a .cljel source, loads it into a real Emacs, and calls the functions:

git clone https://github.com/BuddhiLW/clojure-elisp
cd clojure-elisp/examples/bb-demo && bb demo
Compiled src/demo/greeter.cljel -> out/demo-greeter.el (963 chars)
greet:     Hello, world!
shout:     HELLO, CLJEL!
region:    Hello, alpha!
M-x ready: t

bb nrepl in that directory starts the server below. See its README for the task list.

Start the server

Install the CLI once:

bbin install io.github.BuddhiLW/clojure-elisp

Then, with no JVM, no .nrepl.edn and no jack-in:

clel nrepl                # ClojureElisp nREPL server on port 7888
M-x cider-connect-clj     RET localhost RET 7888 RET
M-x cider-cljel-mode

Compilation is active from the first message, so cider-cljel-mode is only there for the keybindings.

Already inside a Clojure project and want the JVM server instead? Put the middleware in .nrepl.edn and jack in as usual:

;; .nrepl.edn
{:middleware [clojure-elisp.nrepl/wrap-cljel]}
M-x cider-jack-in         (with the :dev alias)
M-x cider-cljel-mode

Both servers run the same compiler and speak the same protocol.

Keys

KeyDoes
C-c C-eCompile and eval the form before point
C-c C-cCompile and eval the top-level form at point
C-c C-kCompile and eval the whole buffer

C-c C-e and C-c C-c send the buffer's (ns ...) form along with the code, so a definition evaluated at point gets the same Elisp name that C-c C-k and clel compile give it. Evaluate (defn greet ...) inside (ns my.pkg) and you get my-pkg-greet every way you compile it.

A session

Building a command that wraps the region in a Markdown code fence. Open fence.cljel, then evaluate as you go.

(ns my.fence)

C-c C-c on the ns form. Now a helper:

(defn fence-text [lang text]
  (str "```" lang "\n" text "\n```"))

C-c C-c. It is defined right now. Try it without leaving the buffer, with C-c C-e after the closing paren:

(fence-text "clojure" "(+ 1 2)")
;; => "```clojure\n(+ 1 2)\n```"

The result appears inline, at point. Now the interactive command:

(defn fence-region [start end]
  (interactive "r")
  (let [text (buffer-substring-no-properties start end)]
    (delete-region start end)
    (insert (fence-text "clojure" text))))

C-c C-c, then select a region and run M-x my-fence-fence-region. It works, because it is a real defun in your live Emacs.

Wrong language hardcoded. Fix it in place:

(defn fence-region [start end lang]
  (interactive "r\nsLanguage: ")
  (let [text (buffer-substring-no-properties start end)]
    (delete-region start end)
    (insert (fence-text lang text))))

C-c C-c again. The old definition is replaced. No recompile, no reload, no restart. When the buffer is right, ship it:

clel compile fence.cljel -o fence.el

The emitted fence.el defines the same my-fence-fence-region you have been calling all along.

Requirements

Compiled code calls runtime functions such as clel-str, so the runtime library clel.el must be loadable. cider-cljel-mode loads it from load-path and tells you if it cannot find it; point cider-cljel-runtime-file at the file to be explicit.

Features

Language

  • Functions: defn, fn (lambda), multi-arity, variadic (& rest), destructuring in params
  • Bindings: let with sequential bindings, vector/map destructuring, :keys, :as, :or
  • Control flow: if, when, cond, case, do, and, or
  • Looping: loop/recur, letfn with mutual recursion
  • Macros: defmacro (compile-time only), syntax-quote/unquote, macroexpand-1, macroexpand
  • Error handling: try/catch/finally, throw, ex-info
  • Namespaces: ns with :require, :as, :refer; namespace-prefixed definitions
  • Protocols & types: defprotocol, defrecord, deftype with ^:mutable fields, set!
  • Multimethods: defmulti/defmethod via cl-defgeneric/cl-defmethod
  • Lazy sequences: lazy-seq, realized?, doall, dorun
  • Atoms: atom, deref/@, reset!, swap!, add-watch, remove-watch
  • Elisp interop: .method dot-notation, elisp/fn namespace, .-property access

Core Functions (100+)

Clojure core functions mapped to Elisp equivalents:

CategoryFunctions
Arithmetic+, -, *, /, mod, inc, dec
Comparison=, <, >, <=, >=, not=
Predicatesnil?, string?, number?, zero?, pos?, neg?, even?, odd?, coll?, some?
Collectionsfirst, rest, next, cons, conj, count, nth, get, assoc, dissoc, keys, vals, into, seq, empty?
Sequencesmap, filter, remove, reduce, take, drop, concat, mapcat, sort, group-by, frequencies
Seq predicatesevery?, some, not-every?, not-any?
Stringsstr, subs, format, pr-str, println
Higher-orderapply, identity, constantly, partial, comp

Compiler

  • 3-stage pipeline: Reader (portable) → Analyzer (AST + env) → Emitter (codegen)
  • Source location tracking with optional ;;; L<line>:C<col> comments
  • Incremental compilation with mtime tracking (.clel-cache/manifest.edn)
  • Cross-file symbol table with compile-time warnings for missing symbols
  • Dependency-aware project compilation with topological sort
  • Name mangling: valid? → valid-p, reset! → reset-bang, my.ns/foo → my-ns-foo

Architecture

┌─────────────┐    ┌──────────────┐    ┌─────────────┐    ┌──────────────┐
│   Reader    │───▶│   Analyzer   │───▶│   Emitter   │───▶│  Elisp Code  │
│ (portable)  │    │ (AST + env)  │    │ (codegen)   │    │   (.el)      │
└─────────────┘    └──────────────┘    └─────────────┘    └──────────────┘
ComponentFileRole
Readersrc/clojure_elisp/reader.cljSource text → forms with :line/:column; the same forms on every host
Analyzersrc/clojure_elisp/analyzer.cljParse forms → AST nodes, macro expansion, destructuring, env tracking
Emittersrc/clojure_elisp/emitter.cljAST nodes → Elisp source strings
Coresrc/clojure_elisp/core.cljPublic API, file/project compilation, dependency resolution
Runtimeresources/clojure-elisp/clel.el (the Emacs package clel)55+ Elisp functions implementing Clojure semantics
MCP Serversrc/clojure_elisp/mcp.cljMCP stdio server exposing compiler as AI tools
CLIsrc/clojure_elisp/cli.cljJVM uberjar entry point (compile, mcp, version)
Portable CLIsrc/clojure_elisp/main.cljcompile/version on the JVM, Babashka and cljw
BB CLIbb/clel/main.cljBabashka CLI frontend (delegates to uberjar)
nREPL kernelsrc/clojure_elisp/nrepl_kernel.cljSession registry, compile modes, op semantics (transport-free)
nREPL middlewaresrc/clojure_elisp/nrepl.cljJVM transport: wrap-cljel for a CIDER jack-in
nREPL serverbb/clel/nrepl_server.cljStandalone transport: clel nrepl, no JVM
Emacs moderesources/clojure-elisp/clojure-elisp-mode.elMajor mode for .cljel files
CIDERresources/clojure-elisp/cider-clojure-elisp.elCIDER client: sends forms, evals the compiled Elisp locally

Installation

Via bbin (recommended)

Install the clel CLI with bbin:

bbin install io.github.BuddhiLW/clojure-elisp

This gives you the clel command globally, including clel nrepl, which compiles in the Babashka process and needs no JVM. Requires Babashka. Only clel mcp and the compile fallback path need the uberjar (see below).

As a Babashka dependency

The compiler and the nREPL server both load under Babashka, so a bb.edn is the whole setup. examples/bb-demo is a working project built this way:

{:deps {io.github.buddhilw/clojure-elisp {:mvn/version "<latest>"}}
 :tasks
 {compile {:requires ([clojure-elisp.core :as clel])
           :task (clel/compile-file "src/my/pkg.cljel" "out/my-pkg.el")}
  runtime {:requires ([clojure-elisp.core :as clel])
           :task (clel/bundle-runtime! "out")}
  nrepl   {:requires ([clel.nrepl-server :as server])
           :task (server/start-server! 7888)}}}

bundle-runtime! writes the runtime, clel.el, out of the dependency, so nothing has to name a path into the ClojureElisp checkout.

Running under cljw (ClojureWasm)

ClojureWasm starts in about 20 ms, so it is the quickest way to compile a file from the command line. The compiler runs on it unchanged, through the portable entry point clojure-elisp.main. cljw does not fetch Maven artifacts; the :cljw alias in deps.edn takes the compile path's dependencies from Git instead (cloned once into ~/.cljw/gitlibs):

# from a clojure-elisp checkout
cljw -A:cljw -m clojure-elisp.main compile src/my_app.cljel -o out/my-app.el
cljw -A:cljw -m clojure-elisp.main compile src/ -o out/
cljw -A:cljw -m clojure-elisp.main compile      # the project in ./clel.edn

The same namespace is the CLI on every host (bb -m clojure-elisp.main ..., clojure -M -m clojure-elisp.main ...), and all of them emit the same bytes: make parity compiles every .cljel in examples/ and test/, plus the runtime, on the JVM, Babashka and cljw, and diffs the results.

Measured wall time, median, compiling examples/demo.cljel (14 lines) and the 1,973-line runtime.cljel (cljw 1.14.7, Babashka 1.13.224, OpenJDK 25 uberjar):

Commandsmall fileruntime.cljel
cljw -A:cljw -m clojure-elisp.main compile143 ms597 ms
bb -m clojure-elisp.main compile239 ms440 ms
java -jar target/clel-<version>.jar compile662 ms850 ms
clel compile (Babashka CLI delegating to the uberjar)692 ms
clojure -M -m clojure-elisp.main compile2,439 ms

cljw wins wherever startup dominates, which is the usual case of one file per save; Babashka's interpreter is quicker on large inputs. On cljw the project build recompiles every file each time, because its java.io.File has no modification times to compare. cljw build cannot yet produce a self-contained binary of the compiler: see CHANGELOG.md for the upstream gaps that block it.

A file with defmacro can crash cljw 1.14.7. The compiler evals each macro, and eval of an fn form corrupts cljw's heap, so the process may segfault a little later, depending on how much it allocates after the macro. Compile such files with Babashka or the JVM until cljw fixes it.

Uberjar

The CLI delegates compilation to a JVM uberjar. Download from GitHub Releases or build from source:

# Build and install
make build install
# => ~/.local/lib/clel.jar

The CLI auto-detects the jar at ~/.local/lib/clel.jar or via $CLEL_JAR. If no jar is found, it falls back to clojure -M -e.

CLI Usage

clel compile                            # Compile project from clel.edn
clel compile src/my_app.cljel -o out/   # Compile a single file
clel compile src/ -o out/               # Compile a directory
clel watch src/ -o out/                 # Watch and recompile on changes
clel nrepl --port 7888                  # Start the nREPL server for CIDER
clel mcp                                # Start MCP stdio server
clel version                            # Print version

Runtime (Emacs Package)

Compiled .el files require the runtime library, the Emacs package clel (resources/clojure-elisp/clel.el, compiled from runtime.cljel by make runtime). Install it via MELPA (once available):

(package-install 'clel)

Or manually copy it from the repo:

cp resources/clojure-elisp/clel.el ~/.emacs.d/site-lisp/

Before 0.8.0 the runtime was clojure-elisp-runtime.el, providing the feature clojure-elisp-runtime. It was renamed because MELPA requires every definition in a package to start with the package's name, and the runtime's all start with clel. Code compiled by 0.8.0 requires clel; recompile code compiled by an earlier version.

Publishing a Package

Give the package's main namespace an :elisp/package attr-map and the compiled file gets the library headers package.el and MELPA read:

(ns my.pkg
  "One-line summary.

   Commentary paragraphs."
  {:elisp/package {:author "Jane Doe <jane@example.org>"
                   :url "https://example.org/my-pkg"
                   :version "0.1.0"
                   :package-requires [[emacs "28.1"]]
                   :keywords ["convenience"]
                   :license "GPL-3.0-or-later"}})

Package-Requires gains (clel "<minimum>") automatically. Other keys: :maintainer, :copyright, :commentary; :author and :maintainer also take a vector. :assisted-by ("Agent:model", or a vector of them) writes the ;; Assisted-by: line MELPA's CONTRIBUTING asks for under Author when an AI assistant helped write the code.

A multi-file package states this once, in clel.edn (or in its main namespace's attr-map), and compile-project gives every file a header:

;; clel.edn
{:source-paths ["src"]
 :output-dir   "."
 :package      {:name "my-pkg" :author "Jane Doe <jane@example.org>"
                :url "https://example.org/my-pkg" :version "0.1.0"
                :package-requires [[emacs "28.1"]] :keywords ["convenience"]
                :license "GPL-3.0-or-later"}}

my-pkg.el, the file named after the package, gets the full header. Every other my-pkg-*.el gets the header package-lint and melpazoid expect of a secondary file: its ns docstring as summary and Commentary, Author and SPDX-License-Identifier, and no Package-Requires. ^:autoload on a defn, define-minor-mode or defcustom name emits ;;;###autoload:

(define-minor-mode ^:autoload my-pkg-mode
  "Toggle my-pkg."
  :global true)

Building from Source

# Build uberjar
clojure -T:build uber
# => target/clel-<version>.jar

Development

# Start REPL with dev dependencies (nREPL, CIDER)
clojure -M:dev

# Run tests (Kaocha)
clojure -M:test

# Check that the JVM, Babashka and cljw emit identical output
make parity

# Build uberjar
clojure -T:build uber

Requirements

  • Clojure 1.12+
  • Java 21+ (for building/running the uberjar)
  • Emacs 28.1+ (for compiled output)

License

MIT

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close