Andrey Listopadov

Programming ligatures in Emacs

For a long time, I was a fan of Hack font. It has really nice language support, great readability at a size of 9pt, and zero with a dot. I love it when zero comes with a dot. Many fonts use zero with a line, to differentiate it from capital O, but on small sizes, it is not great, however, a dot looks fine when both small and big.

But one thing was lacking from Hack, and it is ligature support. I always wanted to try out ligatures, but there were no other fonts that were visually close to Hack, appealing to my taste, and had good programming ligatures support. I’ve tried Fira Code, but it was too curly for my liking, Iosevka was a bit too narrow, and there’s also Hæck, that is based on Hack, and has ligatures, so looked like a perfect candidate. But my eye was caught by JetBrains Mono. While it’s not the same as Hack, it is visually similar, has great ligature support, and looks a bit nicer in Emacs! The Hack font works for me in a terminal, where I don’t need ligatures, while in Emacs something a little thicker is preferable. So I’ve chosen it, but the ligatures are not working out of the box.

Enabling support for ligatures

There are several options for enabling ligatures in Emacs, listed at Fira Code Wiki page. There are four options:

  • composition mode in Emacs Mac port
  • prettify-symbols
  • composition char table
  • font-lock keywords

We’re interested in the composition char table. The basic idea is to provide starting character, and a regular expression, that matches all ligatures starting with that character, and put it to the composition table. Here’s the code for JetBrains Mono:

(dolist (char/ligature-re
         `((?-  . ,(rx (or (or "-->" "-<<" "->>" "-|" "-~" "-<" "->") (+ "-"))))
           (?/  . ,(rx (or (or "/==" "/=" "/>" "/**" "/*") (+ "/"))))
           (?*  . ,(rx (or (or "*>" "*/") (+ "*"))))
           (?<  . ,(rx (or (or "<<=" "<<-" "<|||" "<==>" "<!--" "<=>" "<||" "<|>" "<-<"
                               "<==" "<=<" "<-|" "<~>" "<=|" "<~~" "<$>" "<+>" "</>"
                               "<*>" "<->" "<=" "<|" "<:" "<>"  "<$" "<-" "<~" "<+"
                               "</" "<*")
                           (+ "<"))))
           (?:  . ,(rx (or (or ":?>" "::=" ":>" ":<" ":?" ":=") (+ ":"))))
           (?=  . ,(rx (or (or "=>>" "==>" "=/=" "=!=" "=>" "=:=") (+ "="))))
           (?!  . ,(rx (or (or "!==" "!=") (+ "!"))))
           (?>  . ,(rx (or (or ">>-" ">>=" ">=>" ">]" ">:" ">-" ">=") (+ ">"))))
           (?&  . ,(rx (+ "&")))
           (?|  . ,(rx (or (or "|->" "|||>" "||>" "|=>" "||-" "||=" "|-" "|>"
                               "|]" "|}" "|=")
                           (+ "|"))))
           (?.  . ,(rx (or (or ".?" ".=" ".-" "..<") (+ "."))))
           (?+  . ,(rx (or "+>" (+ "+"))))
           (?\[ . ,(rx (or "[<" "[|")))
           (?\{ . ,(rx "{|"))
           (?\? . ,(rx (or (or "?." "?=" "?:") (+ "?"))))
           (?#  . ,(rx (or (or "#_(" "#[" "#{" "#=" "#!" "#:" "#_" "#?" "#(")
                           (+ "#"))))
           (?\; . ,(rx (+ ";")))
           (?_  . ,(rx (or "_|_" "__")))
           (?~  . ,(rx (or "~~>" "~~" "~>" "~-" "~@")))
           (?$  . ,(rx "$>"))
           (?^  . ,(rx "^="))
           (?\] . ,(rx "]#"))))
  (let ((char (car char/ligature-re))
        (ligature-re (cdr char/ligature-re)))
    (set-char-table-range composition-function-table char
                          `([,ligature-re 0 font-shape-gstring]))))

By enabling auto-composition-mode we can see the ligatures in action:

Figure 1: Ligatures off

Figure 1: Ligatures off

Figure 2: Ligatures on

Figure 2: Ligatures on

Hint: open images in separate tabs and toggle between those to spot the difference.

Note, in order for ligatures to work, Emacs needs to have support for HarfBuzz which was added in Emacs 28.

But I don’t want these ligatures to be enabled everywhere, only in programming-related modes, so here’s a config for composite package:

  (use-package composite
    :hook (prog-mode . auto-composition-mode)
    :init (global-auto-composition-mode -1))

This takes care of most of the errors, listed under composition char table section in Fira Code Wiki. Also by using rx macro we avoid writing complex regular expressions with backslash escape hell, and handle this task to Emacs itself. It’s good when Emacs does boring stuff for us, isn’t it?