GitHub - borkdude/cljbang.el: A Clojure-like language that runs as Emacs Lisp.

GitHub

10 min read Original article ↗

A Clojure-like language that runs as Emacs Lisp.

⚠️ WARNING: I'm not sure if any of this is a good idea, but it kinda works for me.

Cljbang (clj!) compiles Clojure forms to Emacs Lisp forms and evaluates them in the running Emacs. There is no subprocess and no transpiled text.

Cljbang follows the same approach as Squint:

  • Interop uses Emacs Lisp data structures directly.
  • Functions defined in cljbang are callable from Emacs Lisp and vice versa.
  • Compilation is fast, and byte-compiled files load about as fast as Emacs Lisp.
  • Compiled code runs about as fast as Emacs Lisp.

A .clj buffer in Emacs, evaluated inline

Example: which buffers are visiting a file that is no longer there?

(defn stale-buffers []
  (->> (el/buffer-list)
       (filter (fn [b] (let [f (el/buffer-file-name b)]
                         (and f (not (el/file-exists-p f))))))
       (map el/buffer-name)))

The same thing in Emacs Lisp, which is what it compiles to:

(defun stale-buffers ()
  (let (result)
    (dolist (b (buffer-list) (nreverse result))
      (let ((f (buffer-file-name b)))
        (when (and f (not (file-exists-p f)))
          (push (buffer-name b) result))))))

buffer-list, buffer-file-name and file-exists-p are the Emacs functions you already know, reached through el/.

Installation

Requires Emacs 28.1 or later.

(use-package cljbang
  :vc (:url "https://github.com/borkdude/cljbang.el"))

From a clone, add the directory to load-path and require cljbang-mode for inline evaluation:

(add-to-list 'load-path "~/dev/cljbang.el")
(require 'cljbang-mode)

Usage

You can use the clj! macro directly inside an elisp buffer:

(clj! (defn winner [{:keys [alice bob]}]
        (if (> alice bob) :alice :bob))

      (winner {:alice 3 :bob 5}))
;; => :bob

This defines winner in the running Emacs.

For regular use, load a .clj file:

(cljbang-load-file "~/.emacs.d/config.clj")

Or require a namespace:

(cljbang-require 'example)

Loaded files are cached beside the source and reused until it changes. The cache name carries the Emacs and cljbang versions, so upgrading either rebuilds it. Ignore *.clj.*.elc in version control.

Add this file-local variable to a .clj file to enable inline evaluation with C-x C-e:

;; -*- mode: clojure; cljbang-whole-buffer: t -*-

Interning

defn and def intern real elisp symbols for functions and variables. An ns prefixes them using the elisp convention: one dash for public names and two for defn-. Code without an ns uses cljbang-user, like Clojure's user, so it cannot replace an unqualified elisp name such as car. clj! is the exception: it interns names as written to define elisp names directly.

;; my_config.clj
(ns my.config)
(defn greet [x] (str "hello " x))
(defn- shout [x] (upcase x))
(def answer 7)
(cljbang-load-file "my_config.clj")
(my-config-greet "you")   ;; => "hello you"
my-config-answer          ;; => 7
(my-config--shout "hey")  ;; => "HEY"

From cljbang, reach them with the namespace. Dots become dashes, so (ns my.deep.ns) gives my-deep-ns-name:

(my.config/greet "you")   ;; => "hello you"

They are ordinary elisp functions. Arity is checked, interactive makes a command, and C-h f shows the arglist and docstring.

Destructuring reads an alist, which is the shape Emacs passes around:

(defn port [{:keys [host port]}] (str host ":" port))
(port '((:host . "localhost") (:port . 8080)))   ;; => "localhost:8080"

Associative destructuring does not accept plists because they are ambiguous with ordinary lists. A map that cljbang builds is a hash table, so elisp reading one back uses gethash. A set is opaque to elisp, which has no set type.

Require

A :require first looks for a .clj file relative to the requiring file, then on cljbang-load-path. If none exists, it loads an Emacs Lisp feature with that name. lib.some-thing is lib/some_thing.clj.

(ns my.config
  (:require [cljbang.core :refer [el! clj!]]
            [lib.b :as b]          ;; loads lib/b.clj
            [magit :as m]          ;; loads magit now, about 55ms
            [string :as s]         ;; a built-in prefix, nothing to load
            [org :as-alias o]))    ;; alias only, org loads when called

(b/hello "a")
(o/agenda)                       ;; org loads on this call, not before
(el/magit--display-buffer buf)   ;; internal names need el/
el/org-directory                 ;; void until org loads, variables do not autoload
(require '[lib.b :as b])   ;; in a file with no ns form
(cljbang-require 'lib.b)   ;; from elisp

A typo is caught when compiling, since an autoload counts as defined:

Warning (cljbang): magti/status resolves to magti-status, which is not defined

cljbang-warn-unresolved turns that off.

Munging is not reversible: (ns a-b) with c and (ns a) with b-c both intern a-b-c. The second definition warns before replacing the first:

Warning (cljbang): a/b-c interns a-b-c, already a-b/c

Interop

Use el/ to access Emacs Lisp names explicitly:

(el/propertize "hi" 'face 'bold)
(el/make-overlay (el/point) (el/line-end-position))
(el/assoc "b" '(("a" . 1) ("b" . 2)))   ; elisp assoc, not Clojure's
el/tab-width                            ; a variable, not a function

An elisp name with a slash of its own, my/some-var, is not a legal Clojure symbol behind el/. Use el!:

(el! (setq my/some-var 42))
(el! my/some-var)

Unknown bare calls also fall through to Emacs Lisp. Use el/ to avoid unresolved-symbol warnings from clj-kondo.

Use el! for Emacs Lisp macros. A nested clj! compiles a cljbang expression in the surrounding scope:

(el! (use-package magit
       :bind (("C-x g" . magit-status))))

(let [n 3]
  (el! (cl-loop repeat (clj! n) collect :x)))

Inside el!, backquote has Emacs Lisp semantics. Write ~ and ~@ where Emacs Lisp uses , and ,@:

(def todo-file "~/todo.org")
(el! (setq org-capture-templates
           `(("t" "Todo" entry (file ~(clj! todo-file)) "* TODO %?"))))

Standard library

Cljbang has these special forms:

def defn defn- defmacro fn let loop recur set! if do try ns require quote comment

Clojure treats only def, if, do, set!, quote, try, loop and recur as special. The rest are macros there, and could become macros here too.

Macros

Build a macro with a syntax quote, unquoting with ~ and ~@:

(defmacro unless [test & body]
  `(if ~test nil (do ~@body)))
(unless false :ok)                         ;; => :ok

A syntax quote resolves unqualified names in the macro's namespace, including vars defined later. Use el/ for Emacs Lisp names:

(ns my.config)
(defmacro shout [x] `(el/upcase (greet ~x)))   ;; greet is my-config-greet
(defn greet [x] (str "hello " x))              ;; defined after the macro

Each name ending in # gets one fresh symbol per template, so a binding the macro introduces cannot capture one at the call site:

(defmacro my-or [a b]
  `(let [v# ~a] (if v# v# ~b)))
(let [v 5] (my-or nil v))                  ;; => 5

A #(...) in a template is an error, since its % would be qualified like any other name. Write (fn [x#] ...). Building an expansion with list and cons still works.

These ship as macros rather than compiler support:

when cond case if-let when-let doseq dotimes -> ->> some-> some->>
with-out-str time

Functions

Supported functions:

+ - * / mod = not= < > <= >= inc dec not odd? even? zero? pos? neg?
first second rest last nth count get contains? conj assoc seq vec set
map filter remove reduce concat sort sort-by str pr-str println prn name subs
mapv mapcat into range take drop take-while drop-while distinct
some every? empty? apply partial comp complement constantly
keys vals merge dissoc select-keys update get-in assoc-in update-in
re-pattern re-find re-matches re-seq
hash-map hash-set throw ex-info ex-message ex-data ex-cause
slurp spit load-file
atom deref reset! swap!
keyword symbol nil? some? map? set? vector? fn? symbol? keyword?
string? number? integer? int?

An atom derefs with @ as well as deref:

(let [a (atom 0)] (swap! a inc) @a)   ;; => 1

clojure.edn/read-string is available as edn/read-string without a require. false reads as nil, and char and tagged literals are not supported.

clojure.string is available as str without a require:

join split split-lines replace blank? includes? starts-with? ends-with?
index-of last-index-of upper-case lower-case capitalize reverse
trim triml trimr trim-newline
(str/join ", " ["a" "b"])   ;; => "a, b"

str is the only predefined alias. Any other comes from a :require, which clj! can carry too:

(clj! (ns my.config (:require [magit :as m]))
      (m/status))

Cljbang supports map and set literals and nested sequential and associative destructuring in let bindings and function parameters. Sets, maps, keywords and vectors can be called as functions.

(#{1 2 3} 1)                ;; => 1
(:a {:a 1})                 ;; => 1
(filter #{1 3} [1 2 3 4])   ;; => (1 3)

Regex literals pass through to the host engine, as they do in Clojure, where #"(a|b)" is Java's syntax rather than Clojure's. Here the engine is Emacs's, so groups and alternation are spelled \( and \|. A backslash stands for itself:

(re-find #"a+" "baaac")                    ;; => "aaa"
(re-seq #"a." "abac")                      ;; => ("ab" "ac")
(re-find #"\(a\|b\)" "xbx")               ;; => ["b" "b"], a group and an
                                           ;;    alternation
(str/replace "a1b2" #"[0-9]" "#")          ;; => "a#b#"
(str/replace "a.b" "." "!")                ;; => "a!b", a string match is literal

Anonymous function literals, with %, %1, %2 and %&:

(map #(+ % 1) [1 2 3])      ;; => (2 3 4)
(#(list %1 %&) 1 2 3)       ;; => (1 (2 3))

Differences from Clojure

When Clojure and Emacs Lisp semantics differ, cljbang usually uses Emacs Lisp semantics:

(/ 1 2)             ;; => 0, elisp division, no ratios
(nth "abc" 0)       ;; => 97, characters are integers
(if (list) :y :n)   ;; => :n, elisp has no empty list distinct from nil
(if [] :y :n)       ;; => :y, and 0, "" and {} are true as in Clojure

#"\(a\|b\)"         ;; the host engine's syntax, as Clojure's #"(a|b)" is Java's
(assoc m :k 1)      ;; copies the map, so O(n)

(try (f) (catch :default e e))         ;; catches anything like CLJS
(try (f) (catch el/file-missing e e))  ;; or a host error symbol, like js/Error

(get '((:a . 1)) :a)   ;; => 1, an alist reads as a map
(get '((1 2) (3 4)) 1) ;; => (2), and so does a list of lists. Clojure
                       ;;    finds nothing here, but elisp uses lists for
                       ;;    both sequences and maps

These reader forms need source rewriting and do not work inside clj!: #{}, #(), #"", #_, `, ~, ~@ and @. Use hash-set, fn, re-pattern, list and deref, or move the code to a .clj file:

(clj! #{1 2})       ;; read error
(clj! (hash-set 1 2))

From elisp, clj! sees no elisp locals, and unqualified names resolve in cljbang-user.

Not implemented:

  • :strs, :syms and namespaced :keys destructuring.
  • The :while modifier in doseq.
  • Protocols and multimethods.

Shipping a package

Emacs's load-path does not apply to .clj files, so an installed package must register its directory:

;;; myproject.el --- Example cljbang package -*- lexical-binding: t -*-

;; Package-Requires: ((emacs "28.1") (cljbang "0.0.4"))

(require 'cljbang)
(add-to-list 'cljbang-load-path
             (file-name-directory (or load-file-name buffer-file-name)))
(cljbang-require 'myproject.commands)
(provide 'myproject)

Include myproject/commands.clj in the package, and name the namespace after the package, since what it defines is interned under that prefix. A greet there is myproject-commands-greet, an ordinary Emacs Lisp call after requiring myproject.

Do not include *.clj.*.elc files. They are generated caches, not package files.

Clj-kondo

Cljbang includes a clj-kondo configuration. Add cljbang to deps.edn, then import its configuration:

{:deps {io.github.borkdude/cljbang.el {:git/tag "v0.0.9" :git/sha "b0b117656a1b05922e819327c35bf01a31323ccd"}}}
mkdir -p .clj-kondo
clj-kondo --lint "$(clojure -Spath)" --copy-configs --skip-lint

Alternatively, point .clj-kondo/config.edn at a checkout:

{:config-paths
 ["/path/to/cljbang/resources/clj-kondo.exports/borkdude/cljbang"]}

For clj-kondo, refer el! and clj! from cljbang.core:

(ns my.config
  (:require [cljbang.core :refer [el! clj!]]))

Within el!, clj-kondo analyzes only nested clj! forms. Emacs Lisp references do not count as uses of cljbang bindings.

Benchmarks

This is a casual benchmark done on my local machine with Emacs 30.2 and 1000 defns. You can reproduce it with bb bench-load and bb bench.

loading
cljbang-load-file, cold cache 157 ms
cljbang-load-file, warm cache 1.9 ms
byte-compiled plain elisp 1.0 ms
calling all 1000
cljbang 0.40 ms
plain elisp 0.36 ms

Compiled output is plain elisp, so once the cache is warm the overhead is about a millisecond at load and ten percent at run time. Collection operations are slower because map and filter dispatch through a wrapper and assoc copies maps.

Test

License

MIT