Object-oriented font-locking in emacs-lisp

| categories: emacs, fontlock | tags:

I have been interested in functional text for a long time. With functional text you can read it, but also interact with it kitchin-2015-examp. Lately I have been thinking about how to use some features of object-oriented programming to functional text. The premise is to use an object hierarchy to encapsulate some knowledge, and provide functionality on the objects. We can use inheritance to customize some of this knowledge and functionality.

The example I will work out here is to provide functional text for chemical elements. The goal is to define some objects that represent elements, and construct font-lock rules from the objects to make the text functional in Emacs. Functional here means it stands out so we know there is something special about it, it has a tooltip to get some information (like what type of element it is, and its atomic mass), and it is clickable to get more functionality.

This post will make a lot more sense in this video: https://www.youtube.com/watch?v=IWxCj5cr8rY

First, we create a base class of an Element. I use an instance tracker for this to make book keeping easy later. The base class will have a name, synonyms for the name, and a default face to color it. We define a few methods to get an atomic mass and search google for the element. Finally, we provide a function to generate a tooltip, and a font-lock rule.

(defvar atomic-masses '(("Hydrogen" . 1.008)
                        ("Argon" . 39.948)
                        ("Sodium" . 22.989)
                        ("Palladium" . 106.42))
  "a-list of atomic masses.")

(defvar elements '() "List of known elements")
(setq elements '()) ;; this is to start over

(defclass element (eieio-instance-tracker)
  ((tracking-symbol :initform elements
                    :documentation "Variable that holds all class instances.")
   (name :initarg :name
         :documentation "The name of the element")
   (synonyms :initarg :synonyms :initform '()
             :documentation "List of regular expressions that match the element.")
   (face :initarg :face :initform 'font-lock-type-face
         :documentation "The face to use with font-lock."))
  "Base class for a chemical element.")

(defmethod element-atomic-mass ((x element))
  "Return atomic mass from `atomic-masses'."
  (cdr (assoc (oref x :name) atomic-masses)))

(defmethod element-help-echo ((x element))
  "A tooltip for the element.
It will look like class (inherited classes) mass=atomic-mass"
  (format "%s %s: mass=%s"
          (eieio-object-class x)
          (mapcar 'eieio-class-name (eieio-class-parents (eieio-object-class x)))
          (or (element-atomic-mass x) "unknown")))

(defmethod element-search ((x element))
  "Search google for the element"
  (google-this-string nil (oref x :name) t))

(defmethod element-font-lock-rule ((x element))
  "Return font-lock rule for the element."
  (let ((map (make-sparse-keymap)))
    (define-key map [mouse-1]
      (lambda ()
        "Construct the object and run `element-search' on it."
          (get-text-property (point) 'element-name)
          :name 'elements))))

     ;; Construct the pattern to match
     (rx-to-string `(: bow
                       (or  ,(oref x :name)
                            ,@(loop for sy in (oref x :synonyms)
                                    collect `(regexp ,sy)))
     0  ;; font-lock the whole match
     ;; These are the properties to put on the matches
     `(quote (face ,(oref x :face)
                   element-name ,(oref x :name)
                   local-map ,map
                   mouse-face 'highlight
                   help-echo ,(element-help-echo x))))))

Now, we can define some sub-classes. These are families of elements. For a metal, we change the face. For noble gases, we override the help-echo function, and for alkali metals we override the search function. The point is that we can customize the behavior for different classes.

(defclass metal (element)
  ((face :initform '(:foreground "orange" :underline t)))

(defclass noble-gas (element)
  "A noble gas")

(defmethod element-help-echo ((x noble-gas))
  "I am not a common element.")

(defclass alkali (element metal)
  "Alkali metal")

(defmethod element-search ((x alkali))
  (let ((visible-bell t))
    (message "You clicked on an alkali metal: %s." (oref x :name))))

Now we can define some elements. These are all instances of each class. For some, we define synonyms, and alternate appearances. Note the synonyms are regular expressions.

(element :name "Hydrogen" :synonyms '("H" "[hH]ydrogen"))

(noble-gas :name "Argon" :synonyms '("Ar"))

(alkali :name "Sodium" :synonyms '("Na" "[nN]atrium"))
(alkali :name "Potassium" :synonyms '("K") :face '(:foreground "red"))

(metal :name "Palladium")

The instance tracker shows us the defined objects.


1 Font-locking the elements

Here we generate font-lock rules from the set of objects. Each object will return its font-lock rule, so we just map over each object to get the list of rules.

 (mapcar 'element-font-lock-rule elements))


Now any time we have Palladium or Hydrogen it will be highlighted. And Sodium and Argon.

Here are some synonyms: hydrogen H Natrium natrium.

Potassium has a different color than Na.

2 Summary

This seems like a pretty useful way to encapsulate functionality for functional text. Clearly most of the work should go in the base class, and the inheritance model, so you do not have to repeat things unnecessarily. Some features are missing, like conveniently adding synonyms and regenerating the font-lock rules. It is also the case that we do not persist these objects. They could be written to disk so that they can be reloaded later.

The actions you can use on a highlighted word are pretty limited in this implementation. It would be nice if you got a menu of options that was user extendable and dynamic. Either a popup menu, or a hydra would be fine.

Copyright (C) 2017 by John Kitchin. See the License for information about copying.

org-mode source

Org-mode version = 9.0.5

Discuss on Twitter