šŸ’¾ Archived View for gemini.omarpolo.com ā€ŗ post ā€ŗ writing-a-major-mode.gmi captured on 2024-03-21 at 15:31:58. Gemini links have been rewritten to link to archived content

View Raw

More Information

ā¬…ļø Previous capture (2023-01-29)

-=-=-=-=-=-=-

Writing a simple Emacs major-mode

About DSLs and custom major-modes

Written while listening to ā€œNew Millenniumā€ by Dream Theater.

Published: 2021-08-06

Tagged with:

#elisp

#emacs

As part of the regression suite for a project Iā€™m working on, I designed a simple scripting language (which is not even Turing-complete by the way) to create specific situations and test how the program respond. Iā€™ve almost finished the interpreter for it, so itā€™s the time to start writing tests. How do you edit a file if you donā€™t have a proper major mode available? You write one!

A major mode is a lisp program that manage how the user interacts with the content of a buffer. (Friendly remainder that a buffer may or may not be an actual file; things like dired or elpher are major modes after all, but theyā€™re not the kind of modes Iā€™m interested in now.)

Major modes for text files usually do at least three things:

and probably more, like providing useful keybindings and interactions with other packages.

Iā€™ve never had to deal with the fontification or syntax tables, nor realised how difficult the indentation can be, so itā€™s been lots of fun.

The difficulty of writing a major mode seems to be at least proportional to the ā€œcomplexnessā€ of the target language. In my case, the grammar of the language is dead-simple and so the major mode is simple too. cc-mode on the other hand is probably at the other side of the spectrum (well, after all it manages C, C++, Java, AWK and moreā€¦)

Before describing the elisp implementation, hereā€™s a look at the custom DLS, ā€œnpsā€:

include "lib.nps"

# consts comes in two flavors
const (
	one = 1
	two = 2
)
const foo = "hello there"

# procedures works as expected, ā€¦ is for the rest argument
proc message(type, ...) {
	send(type:u8, ...) # type casts
}

# itā€™s a DSL for regression tests after all
testing "cooking skills" {
	message(Make, "me", "a", "sandwitch")
	m = recv()

	# asserts comes in two flavors too
	assert (
		m.type == What
		m.content == "Make it yourself."
	)
	assert m.id = 5
}

Now letā€™s jump in to the mode implementation.

The elisp file starts with the usual header. Iā€™m enabling the lexical-binding even if itā€™s the default from emacs 27

;;; nps-mode.el --- major mode for nps  -*- lexical-binding: t; -*-

Iā€™ll also make use of the rx library to write regexps, so

(eval-when-compile
  (require 'rx))

fontification

i.e. syntax highlighting. There are probably different ways of doing this, but Iā€™ll stick with the simplest one: a bunch of regexps.

(defconst nps--font-lock-defaults
  (let ((keywords '("assert" "const" "include" "proc" "testing"))
        (types '("str" "u8" "u16" "u32")))
    `(((,(rx-to-string `(: (or ,@keywords))) 0 font-lock-keyword-face)
       ("\\([[:word:]]+\\)\s*(" 1 font-lock-function-name-face)
       (,(rx-to-string `(: (or ,@types))) 0 font-lock-type-face)))))

Yes, I got the number of parenthesis wrong (multiple times) at first.

This value will be later set to the buffer-local font-lock-defaults variable. Iā€™ve not yet wrapped my head around the different levels mentioned in the documentation, but the code seems to work. Weā€™re using rx to build a regexp that matches the keywords and using the face ā€˜font-lock-keyword-faceā€™ for the matches. The zero is there because the regexp doesnā€™t have any sub-groups.

The second entry is slightly more complex and interesting. It matches a symbol followed by an open paren and applies the face ā€˜font-lock-function-name-faceā€™ to it. The regexp has a sub-group (the \\( and \\) bit) that matches only the symbol, and the number 1 tells font-lock to highlight only the first match and not the whole regexp.

The third one is like the first, it highlights the ā€œtypesā€.

syntax-table

This is pure black magic, I can assure you. Nah, just kidding. But it looks like.

Itā€™s a very important piece of the major-mode. Various lisps function will inspect the current syntax-table to query over what kind of text the point is. It also interacts with the font-lock and various other parts of Emacs.

This is also the part Iā€™m less confident with. Some major-modes Iā€™ve seen add explicit entries for the braces and the quotes, other doesnā€™t. Iā€™ve decided to be explicit and list all the characters Iā€™m using, just to be sure.

The idea is to specify for each character (or range of characters) some properties. These properties are expressed in a very terse notation using a string. To add entries to the syntax table you need to use ā€˜modify-syntax-entryā€™: it takes the character (or range), the string description of the properties and the syntax table.

The format of the specification is better explained in the elisp manual, but the gist is that is a sequence of character with a special interpretation. The first character identifies the ā€œclassā€ (punctuation, word component, comment delimeter, parenthesis, ā€¦), the second if not a space specifies the matching character, and then there are further fields that I wonā€™t use.

Just to provide an example before showing the code, in a programming language the syntax entry for the character ā€˜(ā€™ probably looks like "()":

The syntax table for ā€˜)ā€™ instead will look like "((" because

So, hereā€™s the syntax table for nps in its all glory:

(defvar nps-mode-syntax-table
  (let ((st (make-syntax-table)))
    (modify-syntax-entry ?\{ "(}" st)
    (modify-syntax-entry ?\} "){" st)
    (modify-syntax-entry ?\( "()" st)

    ;; - and _ are word constituents
    (modify-syntax-entry ?_ "w" st)
    (modify-syntax-entry ?- "w" st)

    ;; both single and double quotes makes strings
    (modify-syntax-entry ?\" "\"" st)
    (modify-syntax-entry ?' "'" st)

    ;; add comments. lua-mode does something similar, so it shouldn't
    ;; bee *too* wrong.
    (modify-syntax-entry ?# "<" st)
    (modify-syntax-entry ?\n ">" st)

    ;; '==' as punctuation
    (modify-syntax-entry ?= ".")
    st))

indentation

Indentation at first doesnā€™t seem like a difficult thing. After all, when weā€™re staring at code we donā€™t have the slightest doubt on how a certain line needs to be indented. Turns out, like most other ā€œobviousā€ things, that coming up with a program that decides how to indent is not that straightforward.

In my case fortunately the logic is pretty simple. The level of the indentation is how nested we are in parenthesis multiplied by the tab-width (because yes, nps uses hard tabs), with the exception of a closing parenthesis which gets indented one level less. Take this snippet for instance:

proc foo(x) {
	y = bar(x.id)
	assert (
		y.thingy = 3
	)
}

The first line, the ā€˜procā€™ declaration, is indented at the zeroth column because we arenā€™t inside a nested pair of parenthesis. The ā€˜yā€™ variable is indented one tab level because itā€™s inside the curly braces. The body of the assert is inside two nested pairs of parenthesis, so itā€™s indented twice. The closing parenthesis of the assert is indented by only one level because of the special case: it should be two, but since itā€™s a closing we drop one indentation level.

The code for ā€˜nps-indent-lineā€™ is probably not the prettiest, but seems to work nonetheless:

(defun nps-indent-line ()
  "Indent current line."
  (let (indent
        boi-p                           ;begin of indent
        move-eol-p
        (point (point)))                ;lisps-2 are truly wonderful
    (save-excursion
      (back-to-indentation)
      (setq indent (car (syntax-ppss))
            boi-p (= point (point)))
      ;; don't indent empty lines if they don't have the in it
      (when (and (eq (char-after) ?\n)
                 (not boi-p))
        (setq indent 0))
      ;; check whether we want to move to the end of line
      (when boi-p
        (setq move-eol-p t))
      ;; decrement the indent if the first character on the line is a
      ;; closer.
      (when (or (eq (char-after) ?\))
                (eq (char-after) ?\}))
        (setq indent (1- indent)))
      ;; indent the line
      (delete-region (line-beginning-position)
                     (point))
      (indent-to (* tab-width indent)))
    (when move-eol-p
      (move-end-of-line nil))))

The real workhorse is ā€˜syntax-ppssā€™ that tells us how deep in parens we are. A better real-world example is probably the indent-line of the go-mode: itā€™s obviously more complex, but itā€™s still manageable.

abbrev table

This is not strictly needed, but itā€™s nice to have. Iā€™m using abbrev tables for various languages to automatically correct some small typos (like ā€˜inculdeā€™ instead of ā€˜includeā€™).

(defvar nps-mode-abbrev-table nil
  "Abbreviation table used in `nps-mode' buffers.")

(define-abbrev-table 'nps-mode-abbrev-table
  '())

Completing the mode

Now that we have all the pieces, letā€™s define the mode:

;;;###autoload
(define-derived-mode nps-mode prog-mode "nps"
  "Major mode for nps files."
  :abbrev-table nps-mode-abbrev-table
  (setq font-lock-defaults nps--font-lock-defaults)
  (setq-local comment-start "#")
  (setq-local comment-start-skip "#+[\t ]*")
  (setq-local indent-line-function #'nps-indent-line)
  (setq-local indent-tabs-mode t))

nps mode derives from prog-mode, a generic mode used for programming language. This way, users can easily define keybindings and options only for programming-related buffers and have a consistent experience. The body of the ā€˜define-derived-modeā€™ macro is just some code that gets executed when the mode is activated. There, we set the font-lock-defaults that was computed previously, define comment-start and comment-start-skip so functions like ā€˜comment-dwimā€™ (M-;) works as expected and setup the ā€˜indent-line-functionā€™. Then, also enable indent tabs mode because nps uses real hard tabs. Thatā€™s it.

Registering this mode to the ā€˜npsā€™ file extension ensures that Emacs will enable nps-mode automatically:

;;;###autoload
(add-to-list 'auto-mode-alist '("\\.nps" . nps-mode))

Sidebar: what are those ā€˜autoloadā€™ comments? Itā€™s a trick used by Emacs to cheat and not load all the code in a file until itā€™s needed. Emacs will only evaluate the ā€˜add-to-listā€™ and register a ā€˜nps-modeā€™ autoload, but wonā€™t evaluate anything else until ā€˜nps-modeā€™ is called. The first time that ā€˜nps-modeā€™ is called, itā€™ll make Emacs load the whole ā€˜nps-mode.elā€™ file and then call again ā€˜nps-modeā€™. This is how Emacs can starts so quickly and still load TONS of emacs-lisp files.

Major modes usually defines also some keys and/or integration with other packages (flymake for example). Iā€™m not going do to neither, but itā€™s still pretty easy. To provide some keys all you have to do is to declare a ā€˜$mode-mapā€™ variable that holds a keymap, then ā€˜define-derived-modeā€™ will take care of enabling it:

(defvar nps-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map "C-c c" #'do-stuff)
    ...
    map)) ; donā€™t forget to return the map here!

Wrapping up

Writing a major-mode from scratch this way was really interesting in my opinion. The knowledge on how major-mode works and how to write one will probably come in handy in the future, either to write more major-mode for (hopefully) real programming languages or to tweak existing ones.

In retrospect, I ended up choosing the hardest possible way to build a major mode. For a project like this, where Iā€™m only interested in basic font-locking, there was at least two other options to choose from:

generic-mode is provide an easy, but limited, way to write major-modes.

cc-mode itā€™s the mode that powers C, C++, Java and (at least) AWK. Itā€™s pretty flexible and it was designed to handle ā€œallā€ C-like programming languages.

However, writing nps-mode from scratch was a pleasant experience and I had some fun hacking in emacs lisp. The implementation is also not too bad and still pretty simple, so it has been worth the time.

Iā€™m not sharing the code in this post because itā€™s part of the aforementioned project that itā€™s still heavily worked on. The code in this post is everything I wrote in nps-mode.el anyway.

Some useful links:

[https] generic-mode

[https] A makefile for Emacs Packages

-- text: CC0 1.0; code: public domain (unless specified otherwise). No copyright here.