EMACS-DOCUMENT

=============>集思广益

创建并发布Emacs package的简单指南

最近写了篇帖子 是关于用Emacs 作为JavaScript开发环境的. 其中一个着重要解决的问题是如何在Emacs中方便的运行JavaScript测试. 我经常使用TDD的开发方式,因此需要经常跳出编辑器去运行测试案例,这让我觉得很麻烦.

我知道Emacs是完全可以做到这一点的,因为已经有了类似的运行测试的mode了,比如RSpec-mode. 基于此,我决定去学习一下Emacs Lisp 然后自己写一个Mocha 测试器. 在这个过程中,我学到了许多关于开发Emacs package的知识,并且最终开发出了一个有用的工具. 我想我有必要分享一下我学到的东西了.

关于这部分的内容有很多,我们这主要关注三个方面知识: 将Emacs作为Lisp IDE,编写简答的package,以及发布该package給他人使用.

1 Emacs as an Emacs Lisp IDE

毋庸置疑, Emacs本身就很适合作为Emacs Lisp代码的开发环境. 它可以很轻易的配置成像IDE那样包括自动补全,文档提示,整合调试以及REPL等功能.

1.1 A few recommendations

虽说Emacs本身就内置了这些IDE的特性,但我还是强烈推荐安装一些第三方的package,比如 company-mode (提供自动补全功能) and Flycheck (提供实时语法监测功能).

我也推荐开启内置的eldoc-mode, 它在你编写代码时为各种函数与symbol提供文档与签名的提示信息.

最后,你应该熟悉那些内置的Emacs Lisp调试与执行相关的函数. 要测试一段代码,你可以开启内置的 Lisp-interaction-mode, *scratch* buffer在默认情况下会开启该mode. 在该mode下,你可以粘贴一段Emacs Lisp代码然后按下 C-x C-e 来运行这段代码并看到运行的结果.

Emacs还内置了一个Edebug 工具, 它可以单步调试Emacs Lisp代码. 它有很多的功能,但是我最常用的是它的 edebug-defun 函数. 它会在函数开始的地方设置一个断点,并在运行到这个函数时触发该断点.

2 Making a Custom Compilation Mode

Mocha是一个命令行工具, Emacs内置有很多函数来运行外部的命令行程序.

2.1 Compilation buffer

在Emacs中最贴近测试运行器的东西应该就是 compilation buffer 了. 它会运行一个外部的命令行进程然后将输出结果显示在buffer中. 这就很适合于运行像编译程序或测试运行器这一类的程序了. 它甚至还有高亮错误与跳转到出错位置的能力.

只需要像下面这样运行 M-x compile 就好了:

wWAwkTuVH2.gif

这种用法比较适用于执行那些固定不变的静态编译命令(默认为make -k). 但是它不是那些适合于作为测试启动器,因为测试启动器有如下要求:

  1. 它需要在一个固定的目录下运行一个测试脚本(M-x compile会使用当前文件所在的目录作为运行脚本的工作目录).
  2. 测试时需要能传递一个动态配置项(例如要测试的文件)給测试脚本

2.2 Custom compilation mode

最终的解决方案是自己创建一个可以接受参数并运行交互式函数的compilation mode. 这个实现起来其实蛮容易的. 只需要几行代码就行了:

(require 'compile)

...

(defvar node-error-regexp-alist
  `((,node-error-regexp 1 2 3)))

(defun mocha-compilation-filter ()
  "Filter function for compilation output."
  (ansi-color-apply-on-region compilation-filter-start (point-max)))

(define-compilation-mode mocha-compilation-mode "Mocha"
  "Mocha compilation mode."
  (progn
    (set (make-local-variable 'compilation-error-regexp-alist) node-error-regexp-alist)
    (add-hook 'compilation-filter-hook 'mocha-compilation-filter nil t)
    ))

这些代码可能有些难懂(托Lisp的福!), 其实它做的事情很简单. 我们使用内置的 define-compilation-mode 宏来定义一个名为 mocha-compilation-mode 的compilation-mode, 并且做了以下两件事情:

  1. 给它传递了一个正则表达式来将Node.js 产生的错误输出映射成文件,行号和列号.
  2. 增加了一个hook来正确解析ANSI escape code.

第一件事让我们可以快速跳转到测试失败的地方. 第二件事可以让输出好看一些.

3 Running Test Commands

现在我们自定义出了一个能正确显示命令输出的compilation mode了, 下一步我们需要定义一个测试命令,并让它能够在我们自定义的compilation mode下被调用. 要做到这一步需要经过以下几步.

3.1 Find project root

许多命令行工具都要求是在项目的根目录下运行的. 好在项目的根目录一般都会有一些特定的文件或目录存在(比如版本控制目录). 由于通过寻找特定文件/目录来确定项目根目录位置的需求太常见了,因此Emacs内建了一个名为 locate-dominating-file 的函数来递归地沿着目录树向上搜索特定的文件名称. 该函数的文档很好的说明了如何使用该函数:

(locate-dominating-file FILE NAME) Look up the directory hierarchy from FILE for a directory containing NAME. Stop at the first parent directory containing a file NAME, and return the directory. Return nil if not found. Instead of a string, NAME can also be a predicate taking one argument (a directory) and returning a non-nil value if that directory is the one for which we’re looking.

3.2 Customize configuration

测试与编译毕竟是不一样的,编译每次的编译命令都是一样的,然而测试时需要动态的生成测试命令. 好在Emacs内置的 Customize 为package提供了一套很棒的配置界面. Customize 预定义了很多宏,这些宏可以用来为package定义配置参数,并提供了一套图形界面来配置这些参数.

例如,下面定义了一些Mocha的配置项:

(defgroup mocha nil
  "Tools for running mocha tests."
  :group 'tools)

(defcustom mocha-which-node "node"
  "The path to the node executable to run."
  :type 'string
  :group 'mocha)

(defcustom mocha-command "mocha"
  "The path to the mocha command to run."
  :type 'string
  :group 'mocha)

(defcustom mocha-environment-variables nil
  "Environment variables to run mocha with."
  :type 'string
  :group 'mocha)

(defcustom mocha-options "--recursive --reporter dot"
  "Command line options to pass to mocha."
  :type 'string
  :group 'mocha)

(defcustom mocha-debug-port "5858"
  "The port number to debug mocha tests at."
  :type 'string
  :group 'mocha)

这些配置项的配置界面如下所示:

GUI interface for configuring our package

由于许多的配置项是用来配置项目信息而不是全局信息的,因此Emacs还支持通过一个名为 .dir-locals.el 的文件来为每个目录设置自己的配置信息. .dir-locals.el 文件的内容大致如下所示:

((nil . (
         (mocha-which-node . "/Users/ajs/.nvm/versions/node/v4.2.2/bin/node")
         (mocha-command . "node_modules/.bin/mocha")
         (mocha-environment-variables . "NODE_ENV=test")
         (mocha-options . "--recursive --reporter dot -t 5000")
         (mocha-project-test-directory . "test")
         )))

上面这段代码可能有点难懂. 这样设置的效果是,如果你的Emacs工作目录处于该 .dir-locals.el 文件的同级目录或子目录下时,Emacs会使用 .dir-locals.el 中配置的信息而不是全局的配置信息.

我们定义好了这些配置项后,很容易就能写出个函数来拼装这些配置信息成一个测试命令了!

(defun mocha-generate-command (debug &optional mocha-file test)
  "The test command to run.
If DEBUG is true, then make this a debug command.
If MOCHA-FILE is specified run just that file otherwise run
MOCHA-PROJECT-TEST-DIRECTORY.
IF TEST is specified run mocha with a grep for just that test."
  (let ((path (or mocha-file mocha-project-test-directory))
        (target (if test (concat "--grep \"" test "\" ") ""))
        (node-command (concat mocha-which-node (if debug (concat " --debug=" mocha-debug-port) "")))
        (options (concat mocha-options (if debug " -t 21600000"))))
    (concat mocha-environment-variables " "
            node-command " "
            mocha-command " "
            options " "
            target
            path)))

4 Generating and Running Compile Command

现在我们可以配置测试命令并且还能找出项目的根目录了, 下一步就是在之前自定义的compilation mode中运行测试命令了. 下面我会向你展示实现该功能的最关键的那些代码,我将这些代码分成几个部分并逐一进行讲解.

(defun mocha-run (&optional mocha-file test)
  "Run mocha in a compilation buffer.
If MOCHA-FILE is specified run just that file otherwise run
MOCHA-PROJECT-TEST-DIRECTORY.
IF TEST is specified run mocha with a grep for just that test."
  (save-some-buffers (not compilation-ask-about-save)
                     (when (boundp 'compilation-save-buffers-predicate)
                       compilation-save-buffers-predicate))

  (when (get-buffer "*mocha tests*")
    (kill-buffer "*mocha tests*"))
  (let ((test-command-to-run (mocha-generate-command nil mocha-file test)) (root-dir (mocha-find-project-root)))
    (with-current-buffer (get-buffer-create "*mocha tests*")
      (setq default-directory root-dir)
      (compilation-start test-command-to-run 'mocha-compilation-mode (lambda (m) (buffer-name))))))

哇塞! 这份代码看起来挺难懂的,让我们一点一点来分析.

4.1 Check for unsaved buffers

该函数作的第一件事就是检查是否还有未保存的buffer存在,如果存在则提示用于先保存. 这项工作看起来挺复杂的,不过对于这种常用的操作,Emacs只需要寥寥数行就能搞定.

(save-some-buffers (not compilation-ask-about-save)
                   (when (boundp 'compilation-save-buffers-predicate)
                     compilation-save-buffers-predicate))

4.2 Clean up test buffer

下一步我们通过搜索运行测试的那个buffer,来看它是否还在运行上一个测试. 若还在运行上一个测试,则我们直接杀掉它另起一个新测试.

(when (get-buffer "*mocha tests*")
  (kill-buffer "*mocha tests*"))

4.3 Bind values

之后,正戏开始了. 我们一开始先设置了两个值:一个时我们要调用的测试命令,另一个是项目的根目录地址. 这两个值都通过上面定义的代码计算出来的.

(let ((test-command-to-run (mocha-generate-command nil mocha-file test)) (root-dir (mocha-find-project-root)))

4.4 Run test command

然后,我们真正运行测试命令. 分三步走:

  1. 创建并切换到一个buffer中,该buffer就是我们测试的运行环境.
  2. 更改工作目录到项目根路径.
  3. 在自定义的compilation mode中运行测试命令.

这三步就对应着代码中的最后三行:

(with-current-buffer (get-buffer-create "*mocha tests*")
  (setq default-directory root-dir)
  (compilation-start test-command-to-run 'mocha-compilation-mode (lambda (m) (buffer-name))))))

4.5 Expose interface to users

现在我们有了运行测试命令的代码了,我们还需要将之暴露給用户使用. Emacs使用interactive functions来实现这一点, interactive function可以被用户通过 "M-x 函数名"或热键的方式调用.

要让一个函数变得可交互, 你只需要将 (interactive) 这句话放在函数体的最开头就行了,像这样:

;;;###autoload
(defun mocha-test-file ()
  "Test the current file."
  (interactive)
  (mocha-run (buffer-file-name)))

习惯上我们常常会将 ;;;###autoload 这个特殊的注释放在函数前面,这个注释会帮助其他引用你package的Emacs文件找到函数定义的位置,这样就可以直接使用该函数(例如你可以为它绑定一个热键)而不用先加载package了.

一旦某个函数被定义为可交互的,它就可以通过 M-x 函数名 的方式被用户所调用.

Interact

自此所有工作就完成了. 仅仅几个函数,我们就为我们的开发环境创造出了一个高度可定制化的测试器.

5 Distributing on MELPA

创建了自己的package后,你想不想把它分享给大家使用呢? Emacs内建了一个package管理器使得你可以很容易向大家分享你的package. 该package管理器支持多种不同的仓库,因此要想发布你的package,只需要将你的package放到其中一个仓库中就行了.

比较常见的三个package仓库有ELPA, Marmalade, 和MELPA. ELPA是GNU官方的仓库,Emacs天生支持该仓库. 相比之下Marmalade 和 MELPA 都是第三方的仓库. 各个仓库之间都有一些不同之处,最大的不同在于它们对版权的处理方式.

ELPA 和 Marmalade 都要求所有的package都遵守GPL 或 GPL-兼容的 协议. 而且, ELPA还要求你签署一份 FSF 版权申明. MELPA则对你的版权没有要求,不过它会对所有新增的package都进行代码审核以保障质量.

你可以自由选择把自己的pacakge放到哪个仓库上去,我自己选择的MELPA仓库,因此我这里就说说把package放到这个仓库的流程吧.

把package放到MELPA中有两个基本步骤.

5.1 Format the package file

首先,你需要安装一定的Emacs Lisp惯例来组织你的package文件, 需要增加一个描述package的头以及其他一些内容. 在编写package文件时很推荐开启 Flycheck, 它会将所有缺失的必须内容标记成错误然后引导你逐一添加这些内容. 添加这些内容是很有必要的,因此Emacs package管理器会将这些内容作为元数据来解析.

5.2 Add your recipe

组织好你的代码之后,你需要在Github上forke MELPA project  然后为你的package添加一份recipe. MELPA有文档告诉你如何配置复杂的package,但是对于简单的单文件package, 编写recipe很简单.

Mocha runner的receipt看起来如下所示:

(mocha
 :repo "scottaj/mocha.el"
 :fetcher github)

就这么简单,仅仅是一个指向Github仓库的路径而已. 添加完了recipe之后,你就可以给MELLPA提交pull request了. 自然有人会审查你的package,也许还会提出一些改动的建议. 所有这些完成之后,你的pull request就会合并到MELPA上去了,MELPA会定期build然后发布你的package. 而且MELPA会直接从你的源代码仓库中拉取代码的,因此你更新package后也无需对MELPA作任何操作. 它会自动拉取最新版本的代码.

至此,这篇创建并发布Emacs package的简单指南就完结了. 你可以在 here 找到上例中的 Mocha.el package,也可以在 这里 看到我的Emacs配置信息. 如果你有疑问,欢迎留言!