easimonenko Evgeny Simonenko

Как написать свой режим для GNU Emacs и опубликовать его в MELPA

18 Aug 2023 |  Tutorial  |  GNU Emacs   Emacs Lisp  

Некоторое время назад я разработал режим GNU Emacs для редактирования конфигурационных файлов операционной системы Embox. Кроме всестороннего изучения Emacs Lisp мне потребовалось разобраться со структурой модуля режима, а также процессом и требованиями к публикации пакетов в MELPA, наиболее популярном архиве пакетов для GNU Emacs. В этом руководстве я расскажу, что нужно знать, чтобы написать свой собственный режим, и как опубликовать свой собственный пакет.

;;;###autoload
(define-derived-mode mybuild-mode prog-mode "Mybuild"
  "Major mode for editing Mybuild files from Embox operating system."
  :syntax-table mybuild-mode-syntax-table
  (setq-local comment-start "// ")
  (setq-local comment-end "")
  (setq-local indent-tabs-mode nil)
  (setq-local indent-line-function 'mybuild-mode-indent-line)
  (setq-local font-lock-defaults '(mybuild-highlights)))

Введение

В Emacs есть два вида режимов:

Главный режим является основным для буфера, а дополнительные предоставляют функции, отсутствующие в основном. Причём зачастую это такие функции, которые применимы для различных главных режимов.

Например, за редактирование файлов с кодом на Emacs Lisp отвечает emacs-lisp-mode. Это major-mode. Он знает как делать синтаксическую подсветку, как делать правильные отступы и даже даёт информацию о функциях в minibuffer. Но он не предоставляет таких удобных функций, как автодополнение и проверка кода на лету. Это поле деятельности для minor-mode. Для автодополнения служит, например, company, а для проверок flymake.

В этой статье мы рассматрим разработку только главного режима, но с дополнительными дело будет обстоять примерно также.

Emacs различает три вида главных режимов:

text-mode предназначены для редактирования текстовых файлов: плоского (txt), форматированного (org, md) и структурированного текста (html, json).

prog-mode применяется к языкам программирования.

special-mode не привязывается к конкретному файлу и служит для создания приложений, например, таких как magit для работы с репозиториями Git, dired для работы с файловой системой, tetris.

В этой статье мы посмотрим на разработку собственного prog-mode. Выбор для конфигурационных файлов Embox prog-mode обусловлен их синтаксисом, предельно похожим на C-подобные (только без точки с запятой).

Конфигурационный файл Mybuild выглядит примерно так:

package arduino_due.examples

module blinking_led {
       depends embox.driver.gpio.sam3
       source "blinking_led.c"
}

Общие моменты и задачи

Первое что нужно решить при разработке своего режима: как далеко вы готовы зайти? Чем больше у вас опыта с GNU Emacs и Emacs Lisp, тем больше шансов, что запланированное будет реализовано. Если опыта мало, то можно ограничиться синтаксической подсветкой и, возможно, организацией правильных отступов, что гораздо сложнее сделать.

Втором моментом будет выбор базового режима. Как уже было сказано во введении, в Emacs бывает три вида основных режимов. Если вы хотете добавить поддержку абсолютно нового языка разметки, то нужно базироваться на text-mode. Если для абсолютно нового языка программирования с оригинальным синтаксисом, то берите prog-mode. Если же хотите написать приложение, то вам нужен special-mode. Хорошая новость состоит в том, что если ваш проект не сильно оригинален, то можно взять за основу более развитый режим, например, для языков с C-подобным синтаксисом можно взять c-mode. Плохая новость: вряд ли базовый режим будет работать с вашим кодом идеально, поэтому часть функций предётся написать с нуля.

Если собираетесь написать режим для языка разметки, конфигурации или программирования, то хорошим подспорьем станет грамматика для него. Если будете писать для языка программирования, то выясните полный список ключевых слов, формат комментариев и особенности форматирования. Это быдет тем минимумом, который вам нужно будет реализовать.

Придумайте название для своего режима. Для языков оно обычно составляется из названия языка и суффикса -mode. В моём случае это mybuild-mode. У приложений название может быть каким угодно, в качестве разделителя следует использовать дефис -. Все символы должны быть строчными.

Далее перейдём к структуре проекта.

Структура главного модуля режима

Главный модуль режима должен называться также как и сам режим с добавлением расширения имени файла .el (что значит Emacs Lisp).

Рассмотрим здесь основные моменты, а за полным текстом модуля из моего проекта обращайтесь по ссылке.

Документация

Модуль режима должен начинаться с комментариев, формат которых довольно строго задан. Понять, всё ли вы написали правильно, вам помогут 1) подсказки самого Emacs, 2) команда checkdoc и 3) требования MELPA (об этом будет ниже).

Первая строка включает в себя три части:

  1. имя файла модуля
  2. затем три тире и очень краткое описание, что делает ваш режим
  3. директива Emacs по включению лексического связывания

В моём случае первая строка выглядит так:

;;; mybuild-mode.el --- Major mode for editing Mybuild files from Embox  -*- lexical-binding: t; -*-

Если вы не знакомы с Emacs Lisp, то я буду давать некоторые пояснения. Итак, строки комментариев начинаются с точки с запятой ;. С помощью дополнительных ; можно задавать своего рода заголовки разных уровней. Чаще всего заголовки выделяют тремя символами комментариев, сам текст документации – двумя, а проходной комментарий в коде – одним.

В Lisp применяется так называемое динамическое связывание. Это когда встречающиеся имена в функциях ассоциируются с именами в вызвавшем её модуле. При лексическом связывании эти имена соответствуют именам в том же модуле, где находится тело функции. Последний механизм более распространён в других языках, нежели первый. В Emacs Lisp также основным является динамическое связывание, но можно включать и лексическое в пределах данного модуля. Управляющие MELPA считают, что лексическое связывание должно использоваться всегда.

Во второй строке помещается копирайт. Например:

;; Copyright (c) 2022 Evgeny Simonenko

Затем вы указываете:

Замечу, что правильные зависимости можно определить автоматически, вызвав package-lint (см. ниже).

Далее, под заголовком License вы указывете лицензию на код пакета. Ниже пример текста для лицензии GNU GPL v3:

;;; License:
;;
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.
;;
;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <http://www.gnu.org/licenses/>.

Под этой лицензией опубликован как сам GNU Emacs, так и встроенные пакеты.

Под заголовком Commentary помещаем развёрнутое описание вашего режима. Обычно здесь кратко описывают функции режима, как с ним работать и как его настраивать. Свой режим я описал так:

;;; Commentary:
;;
;; Major mode for editing Mybuild files from Embox operating system
;;
;; mybuild-mode supports:
;;
;; * syntax highlighting;
;; * proper indentations;
;; * autoload for Mybuild, *.my, mods.conf files.
;;
;; Customization
;; -------------
;;
;; You can set the width of the indentation by setting the customizable user
;; option variable mybuild-indent-offset from customization group mybuild.
;; By default, it is set to 2.

Код

Не заскучали? Переходим к самому интересному! Заголовком Code предваряется код вашего модуля.

;;;###autoload
(define-derived-mode mybuild-mode prog-mode "Mybuild"
  "Major mode for editing Mybuild files from Embox operating system."
  :syntax-table mybuild-mode-syntax-table
  (setq-local comment-start "// ")
  (setq-local comment-end "")
  (setq-local indent-tabs-mode nil)
  (setq-local indent-line-function 'mybuild-mode-indent-line)
  (setq-local font-lock-defaults '(mybuild-highlights)))

Для объявления своего режима нам нужно воспользоваться специальной формой define-derived-mode. Специальными формами в Lisp (и Scheme) называют конструкции похожие на вызов функции, но устроенные по другому, так что они не могут быть реализованы через функции. (Впрочем, как и макросы, призванные формировать код на основе описания).

Директива ;;;###autoload указывает Emacs автоматически выполнять помеченный ею код, а значит запускать наш режим, когда это необходимо. А когда необходимо? А вот, когда:

;;;###autoload
(add-to-list 'auto-mode-alist '("\\(?:/Mybuild\\|\\.my\\|/mods\\.conf\\)\\'" . mybuild-mode))

Этот код выполняется в обязательном порядке для того, чтобы задать ассоциацию файлов с именами Mybuild или имеющими расширения my, mods, conf с нашим режимом.

В define-derived-mode мы передаём название режима, родительский режим (см. введение), человеко-читаемое название режима. В следующей строке даётся краткое описание режима (в принципе, такое же как и в первой строке-комментарии). Далее задаём переменную с описанием “синтаксиса” языка задавая пару :syntax-table. После чего задаём основные параменты режима.

Синтаксическая таблица для конфгурационных файлов Mybuild получиласть такая:

(defvar mybuild-mode-syntax-table
  (let ((st (make-syntax-table)))
    (modify-syntax-entry ?@ "w" st)
    (modify-syntax-entry ?_ "w" st)
    (modify-syntax-entry ?\{ "(}" st)
    (modify-syntax-entry ?\} "){" st)
    (modify-syntax-entry ?\( "()" st)
    (modify-syntax-entry ?\) ")(" st)
    (modify-syntax-entry ?\/ ". 124b" st)
    (modify-syntax-entry ?* ". 23" st)
    (modify-syntax-entry ?\n "> b" st)
    st)
  "Syntax table for `mybuild-mode'.")

Специальная форма defvar предназначена для объявления переменных. Язык Lisp в отличие от Haskell и отчасти от Scheme не является чистым, и в нём есть переменные. Объявление переменной состоит из её имени, выражения, результат которого ей будет присвоен, и строки документации.

Для создания синтаксической теблицы вызывается функции make-syntax-table. После чего мы вносим в неё записи посредством вызова функции modify-syntax-entry. В нашем примере мы описываем, из чего состоят имена “переменных” (модификатор w), парные скобки (модификаторы вида (}), символы, открывающие и закрывающие комментарии. (Соглашусь с вами, если вам это всё показалось весьма замысловатым. Мне пришлось потратить немало времени, чтобы разобраться с этим и получить требуемый результат.)

Если мы вернёмся к объявлению режима, то увидим, что здесь также задаётся описание комментариев:

(setq-local comment-start "// ")
(setq-local comment-end "")

Здесь задаются символы начала и конца строчных комментариев, а выше давалось описание для блочных.

Специальная форма setq-local присваивает значение локальной переменной.

В GNU Emacs в последних версиях и до выхода версии 29 для синтаксической подстветки следовало использовать встроенный font-lock-mode. Чтобы его активировать мы задаём переменную font-lock-defaults:

(setq-local font-lock-defaults '(mybuild-highlights))

'(mybuild-highlights) означает, что у нас есть переменная, описывающая, как правильно подсвечивать различные фрагменты кода, и мы передаём ссылку на неё.

(defvar mybuild-highlights
  `(("'''[^z-a]*?'''" . 'font-lock-string-face)
    ("@[A-Za-z][A-Za-z0-9-+_]*" . 'font-lock-preprocessor-face)
    ( ,(regexp-opt mybuild-keywords 'words) . 'font-lock-keyword-face)
    ( ,(regexp-opt mybuild-types 'words) . 'font-lock-type-face)
    ( ,(regexp-opt mybuild-constants 'words) . 'font-lock-constant-face)
    ("[A-Za-z][A-Za-z0-9-+/_]*" . 'font-lock-function-name-face))
  "Mybuild syntax highlighting with `'font-lock-mode'.")

Этот код менее замысловат, чем описание синтаксической таблицы, но тоже требует пояснений. Символ обратной кавычки ``` обозначает формирование списка из перечисленных далее значений. Символ точки . обозначает конструктор пары (cons).

С помощью регулярных выражений мы описываем, что считать строками, ключевыми словами, константами, именами функций и текстом для препоцессора. Нужно понимать, что это всё условности, и за текстом для препроцессора у вас могут скрываться директивы компилятора, да и вообще всё, что угодно. Для удобного построения регулярных выражений используются операция , и regexp-opt. С их помощью из списка слов формируется регулярное выражение для альтернативных строк. Сами списки слов описываем в сооветствующих переменных:

(defvar mybuild-keywords
  '("package" "import" "annotation" "interface" "extends" "feature" "module"
    "static" "abstract" "depends" "provides" "requires" "source" "object" "option"
    "configuration" "include"))

(defvar mybuild-types
  '("string" "number" "boolean"))

(defvar mybuild-constants
  '("true" "false"))

К слову сказать, в недавно выпущенной версии 29 предложен прогрессивный метод для написания синтаксических парсеров tree-sitter-mode, использующий грамматики. Подробнее об этом можно прочитать в статье Tree-sitter: обзор инкрементального парсера.

Продолжим анализировать описание режима. Следующая строка задаёт отказ от использования символа табуляции для формирования отступов:

(setq-local indent-tabs-mode nil)

Одна из важнейших и при этом весьма сложных функций режима – формирование правильных отступов. Функция, которая будет вызываться каждый раз, как вы нажимаете клавишу Enter или Tab, задаётся в переменной indent-line-function:

(setq-local indent-line-function 'mybuild-mode-indent-line)

Так как формирование отступов очень сильно разнится, то мы рассмотрим лишь основные моменты этого кода:

(defun mybuild-mode-indent-line ()
  "Indent current line for `mybuild-mode'."
  (interactive)
  (let ((indent-col 0))
    (save-excursion
      (beginning-of-line)
      (condition-case nil
          (while t
            (backward-up-list 1)
            (when (looking-at "[{]")
              (setq indent-col (+ indent-col mybuild-indent-offset))))
        (error nil)))
    (save-excursion
      (back-to-indentation)
      (when (and (looking-at "[}]") (>= indent-col mybuild-indent-offset))
        (setq indent-col (- indent-col mybuild-indent-offset))))
    (indent-line-to indent-col)))

Специальная форма defun объявляет функцию. В нашем случае ей не передаются аргументы, поэтому в описании фигурирует пустая пара скобок (). Вызов interactive сообщает Emacs, что эта функция может вызываться интерактивно, что может быть полезно для её ручной отладки.

Для файлов Mybuild применяется соглашение об отступах для C-подобных языков, но жизнь сильно портит то, что здесь не используется точка с запятой, из-за чего вынуждены использовать самописную функцию вместо доступной в c-mode.

Функция save-excursion запоминает текущую позицию курсора в буфере. beginning-of-line помещает курсов в начало строки. backward-up-list переводит искомую позицию на строку вверх, чтобы затем с помощью looking-at обнаружить позицию открывающей скобки. Если скобка найдена, то величина отступа indent-col увеличивается на значение в настройке mybuild-indent-offset (о настройках режима чуть ниже). Затем ищем закрывающую скобку и уменьшаем величину отступа. С помощью indent-line-to делаем отступ на ранее посчитанное значение indent-col. Проще говоря, если мы входим в блок, ограниченный {, то уведичиваем отступ, а если выходим, вводя ], то уменьшаем.

Осталось совсем чуть-чуть, лишь пара моментов: настройки комбинаций клавиш и настройки режима.

Комбинации клавиш, позволяющих вызывать некоторые функции режима, задаются комбинацией вызовов функций make-keymap и define-key:

(defvar mybuild-mode-map
  (let ((map (make-keymap)))
    (define-key map "\C-j" 'newline-and-indent)
    map)
  "Keymap for `mybuild-mode'.")

Здесь мы задаём, что с помощью Ctrl-j будет вызываться функция формирования правильных отступов.

Одна из часто встречающихся настроек режимов для языков – величина отступа. Чтобы создать такую настройку, воспользуемся специальными формами defgroup и defcustom. После чего пользователь сможет, используя интерфейс редактора задавать нужное ему значение отступа.

(defgroup mybuild nil
  "Customization variables for Mybuild mode."
  :group 'languages
  :tag "Mybuild")

(defcustom mybuild-indent-offset 2
  "Indentation offset for `mybuild-mode'."
  :group 'mybuild
  :type 'integer
  :safe 'integerp)

С помощью defgroup мы создали группу настроект mybuild для нашего режима. В интерфейса она будет доступна по имени Mybuild. Затем с помощью defcustom добавили в группу mybuild настройку mybuild-indent-offset со значением по-умолчанию равным 2, имеющую целый тип и функцию проверки введённого значения с помощью предиката integerp.

Наконец, код модуля завершается комментарием вида:

;;; mybuild-mode.el ends here

Ну, что же, самая трудная, и пожалуй самая интересная часть позади. Дальше немного рутины.

Структура пакета

Кроме главного и вспомогательных модулей в каталоге проекта должны находиться:

В README дайте краткое описание режима, перечислете его возможности, можете указать также, что режим не умеет. Затем дайте полное описание, как пользоваться вашим режимом, как его настраивать. Также нелохо описать, как режим работает, от каких других режимов или программ зависит. Наконец, укажите лицензию на код пакета и авторство.

После публикации пакета в MELPA вы можете добавить бэджики со ссылками на странички пакета.

Так как публикация в MELPA предполагает, что исходники пакета размещены в репозитории Git (или Mercurial), то вы также туда поместите файл .gitignore.

Мой .gitignore такой:

# Compiled
*.elc

# Packaging
.cask

# Backup files
*~

# Undo-tree save-files
*.~undo-tree

Процесс публикации пакета в MELPA

Теперь нам нужно опубликовать наш режим в MELPA. Процесс этот не сложный, но и не тривиальный. Основные шаги следующие:

  1. Зайдите в свой аккаунт на GitHub и сделайте форк репозитория MELPA.
  2. Напишите файл рецепта (Recipe File) для своего режима и добавьте его в свой форк MELPA.
  3. Проверьте свой пакет на предмет соответствия требованиям MELPA.
  4. Сделайте запрос на публикацию изменений (Pull Request) для своего рецепта режима.
  5. Дождитесь проверки от поддержки MELPA.
  6. Внесите требуемые исправления и опубликуйте их.
  7. Оставьте комментарий к своему PR, что всё готово.
  8. Если всё хорошо, ваш пакет появится в архиве MELPA.
  9. Если же вы не всё исправили, или обнаружились другие ошибки, то см. п. 6.

Файл рецепта пишется на Emacs Lisp и должен иметь структуру описанную в README MELPA. В простейшей форме рецепт будет выглядеть аналогично моему:

(mybuild-mode :fetcher github :repo "easimonenko/mybuild-mode")

Т.е. сначала пишем название пакета режима, затем указываем, где располагается его репозиторий. Кроме GitHub поддерживаются и другие хранилища пакетов, анпример, GitLab, репозитории Git и Mercurial. Ещё вам может пригодиться опция :files, позволяющая перечислить файлы, включаемые в пакет, а также исключаемые с помощью вложенной опции :exclude. Вероятно, вам не потребуется это делать.

Сам файл рецепта нужно назвать именем вашего пакета.

После того, как пакт будет опубликован, MELPA будет следить за обновлениями его репозитория, и каждый раз, как вы будете делать коммиты, MELPA будет публиковать свежую версию пакета. Для публикации пакета в MELPA Stable необходимо сделать в репозитории хотя бы один тег (tag) или релиз (release). Аналогично, после публикации очередного тега или релиза, будет создаваться новая версия пакета.

При публикацией PR вам нужно будет заполнить данные по предложенному шаблону и пройтись по списку требований:

  1. Лицензия должна быть свободной и совместимой GNU GPL. Мой выбор GPL v3, т.е. лицензия под которой распространяется сам GNU Emacs.
  2. Нужно прочитать документ CONTRIBUTING, в котором описываются особенности публикации нового пакета.
  3. Код пакета нужно проверить с помощью package-lint.
  4. Выполните компиляцию кода на Emacs Lisp в байт-код с помощью команды M-x byte-compile-file.
  5. Проверьте документацию режима с помощью команды M-x checkdoc.
  6. Произведите локальную сборку и установку пакета.

Первые пять пунктов достаточно понятны. А как сделать шестой описано в том же CONTRIBUTING. Для это нам потребуеся утилита make. Переходим в каталог форка MELPA, куда мы поместили наш файл recipe, и вызываем:

make recipe/mybuild-mode

Затем вызываем в Emacs команду M-x package-install-file, чтобы установить собранный пакет из получившегося архива.

Заключение

Подведём итог: мы разобрались, каков процесс разработки своих режимов и пакетов MELPA, посмотрели на структуру модуля пакета, и что нужно знать, чтобы написать свой prog-mode. Конечно, всё это выглядит достаточно пугающим, и в целом это так и есть: ведь нам нужно не просто овладеть технологией, но и научиться программировать на Lisp и писать код для GNU Emacs, что само по себе не является тривиальным. Но не будем унывать, ведь разве можно жить с Emacs, если не любишь его? ;) Удачи!

Что ещё почитать

Ссылки

(c) Симоненко Евгений, 2023