HOME

编写第一个 Sublime 插件 —— BuildX

从我接触计算机开始到现在 Sublime 一直是我的主力编辑器,现代化的 UI、流畅的速度以及众多的插件,特别是各种开箱即用的特性,让他一直默默成为我的生产力助手。

我尽量避免使用各种 IDE,除非万不得以,有些工作不使用配套的工具十分麻烦,比如苹果的 XCode 和谷歌的 Android Studio。如果做 Android/iOS 开发,不用这些工具当然可以,但是绝对是不推荐的方案。第三方工具永远不会有官方的工具更新的快,同时,各种教程资料也不会针对第三方工具,一旦遇到问题只能靠自己解决,这个时间投入,是非常不值得的。

幸好我的主要技术栈都不需要 IDE,JS/HTML/CSS, C, Go 这些语言 IDE 当然可以有些帮助,但是和 IDE 的臃肿卡顿比起来,这些帮助就显得不重要了。

Sublime && Vim

虽然 Sublime 满足了我的各种需要,但是我心里其实一直在积累换掉他的想法,原因是我觉得 Sublime 的定制性不够。当然,现在回过头看,这是一个偏见,或者说这个论点不够站得住脚。

因为 Sublime 是闭源的关系,再加上各种讨论也不多,不像 Vim/Emacs 我时常能在各种论坛上看到帖子讨论他们,给人的感觉是社区很不活跃。所以我也一直没有花时间去了解 Sublime 的插件系统和 API。一般都是遇到问题的时候才去 Package Control 上找一找有没有现成的插件。

顺手提一下,Package Control 在国内被墙了,需要在配置里面开启一下 http_proxyhttps_proxy

上一次安装 Sublime Go 的时候花了一些时间,我对 Go 的要求很简单,保存时自动运行一下 goimports 对我来说就够了。但是 Sublime Go 这个插件要做到这个都非常麻烦,首先安装就很麻烦,无法使用 Package Control 只能手动安装。配置也十分不便,因为 Go 的插件其实都依赖于 Go 的一些 utility 工具,比如 goimportsgopls 等等。

相比之下 Vim 的 vim-go 以及 VS Code 的 Go 插件使用体验都要好得多,而且安装也方便地多。尤其是 VS Code,对各种语言的支持真的非常好。

当时我就觉得,对于 Go 这样一个热门的语言,Sublime 的支持都是如此的差,只能说明 Sublime 的生态实在是江河日下。

这些年也能明显感觉到 Sublime 的圈子在缩小,参与的人变得越来越少,很多插件的最后更新时间都停留在很久之前。不难猜测,大部分开发者应该是转到 VS Code 上去了。

我对各种基于 Web 的桌面应用程序态度是能不用就不用,我始终无法忍受那种时不时的卡顿感。VS Code 确实很不错,功能,界面,易用性都很棒,但不是我的类型。

因为偶尔的这些不愉快体验,Sublime 虽然在大部分中时间完美契合了我的需要,但是在我眼中他是一个定制性不够高,社区不够活跃,未来也不够光明(不开源由私人公司控制)的编辑器。

选择技术学习时我的看法一向是学习那些 能够持久的技术,这样知识的积累才会带来价值。所以很自然地,我一直有一个计划要把我的整个工作流迁移到 Vim 上,Vim 毫无疑问满足我的各项要求:

  • 足够高的定制性
  • 足够大的社区
  • 对 Vim 的知识积累会带来价值,越使用,越了解,越熟练。

有一次在看 Handmade Hero 时,我实在太想要 Emacs 中那个构建、显示错误信息、跳转到错误行 这个功能了,大大解放了生产力。

Sublime 自带的构建系统可以构建也可以跳转到错误行,但是,构建信息是显示在底部的而不是侧边,看起来非常不方便。我使用了 sublime text 2 buildview 这个插件将构建信息显示到侧边,但是这个插件又不能跳转到错误行,以至于每次我都要自己看错误信息,然后手动跳转,别提多烦了。

Sublime 默认的构建是输出在底部,位置无法调整:

为了这个功能,也为了以后 N 个想要的功能,我下定决心迁移到 Vim。前后花了大概一个月的时间才把我想要的各项功能都在 Vim 中实现了。

我先是通过 Learn Vimscript the Hard Way 系统地学习了 Vimscript。

然后花了大量的时间阅读 Vim 的手册,从 :help usr_01.txt 开始,理解 Vim 中的各个概念,Buffer, Window, Tab, Session, Mode, Syntax, Map, Text Object 等等等等。

最后通读了 Practical Vim 这本书。

接下来就是安装配置各种插件,阅读插件的文档以及根据各种网络上的 .vimrc 来配置自己的 .vimrc,时不时再读一下各种 setting 的文档,比如 :help shiftround 理解一下配置项到底在干什么。

比如下面这是一个很常见的 Vim 配置,但是 shiftwidthsofttabstop 以及 tabstop 有什么区别?设置成不一样会怎么样?

set expandtab " tabs are spaces
set shiftwidth=2
set softtabstop=2
set tabstop=2

要真的理解 Vim 掌握 Vim 毫无疑问是要花费大量时间的。

粗略列一下我在 Vim 中配置的一些功能:

  • Buffer 管理(删除,重命名,快速切换)
  • 文件快速切换 (fzf)
  • 项目快速切换 (fzf)
  • 构建系统 (Make, AsyncBuild, QuickFix)
  • 搜索替换 (Ripgrep)
  • 侧边栏 (NERDTree)
  • Tag 管理,符号跳转
  • Statusline/Tabline 配置 (Lightline)

Vim 的特点是,功能都可以实现,但是需要你自己配置,而且往往会有一些小的瑕疵,让你非常难受。比如,NERDTree 这个大名鼎鼎的 Vim 插件,我用下来真是感觉太一般了。外部新建的文件需要手动刷新才能看到,当然,你可以用一些技术来实现“自动刷新”,比如监听 Cursor 事件,但是这样对新手来说是很不友好的。再比如说每次搜索以后,搜索的高亮不会消失,需要手动让它消失,这实在是非常烦人,当然,如果你花了很多时间,你就会知道 vim-cool 这个插件能帮你解决问题。

总体来说,迁移是成功的,但是在某个阳光明媚的清晨,我决定还是换回 Sublime。

Vim 的定制性是很强,这没问题,问题是这个定制性带给我的价值不多却大大浪费了我的时间。我犯了一个严重的错误:针对少数情况优化

Sublime 让我不满意的时候非常少,可以说占比 5%,在 95% 的时间里,他完美解决了我的各种需要。但是为了解决那 5% 的不满,我推翻了整个系统,从头来过,到头来我还是发现,Vim 一样会让我不满,甚至还不止 5%。

现在回过头来说 Sublime 的那几个问题。

定制性不够高?不然,Sulbime 有一套丰富的 API,没有 Vim/Emacs 那么丰富,但是也足够用了。

社区不够活跃?这个问题确实存在,但是对我的影响并不大,我常用的功能都已经被覆盖,不常用的功能我可以自己开发,社区不够活跃我可以努力成为活跃的一员。

未来不够明朗?现在的 Sublime 已经足够优秀了,未来不更新了都没关系,他只是一个工具,工具的使命是帮助我完成任务。何况 Sublime 现在背后的公司 Sublime HQ 看起来发展的不错,毕竟 Sulbime 销量很好,赚到了钱才有动力开发更好的软件。再说,Sublime 未来完全可以像 Textmate 那样直接开源。

所以,我担心的那些问题其实并不成立,Sublime 作为一个优秀的工具,他有他的不足,任何工具都会有所不足,解决这些不足的方法并不总是换掉这个工具。

既然 Sublime 已有的插件不能满足我的需求,为什么不能自己开发一款?抱着这个态度,我读了一下 Sublime 的插件文档,学习了一些插件的源码,我发现,Sublime 的插件系统设计的很好,接口文档也很清楚,我想要的功能,实现起来很简单。

Sublime && Plugin System

先来简单介绍一下 Sublime 的插件体系。

首先,Sublime 的插件使用的是 Python,比起 Vimscript 来说实在是舒服太多了。

Sublime 所有的插件都在 Sublime 的插件目录下。打开 Command Palette,输入 Preferences: Browse Packages 就可以打开插件目录。在 Mac 上,这个目录是 /Users/__USERNAME__/Library/Application Support/Sublime Text 3/Packages

这个目录中的任何 .py 文件以及一级子目录中的 .py 文件都会自动被 Sublime 识别为插件文件并运行。

点击 Sublime 底部左边的三个点选择 Console 打开 Sublime 的控制台,在 Mac 上快捷键是 ctrl+` 。在插件目录中新建一个文件 touch a.py,会看到控制台输出一行信息 reloading plugin a

插件文件更新时 Sublime 会自动重新运行,所以开发插件体验其实非常好,直接保存就可以了。

一般我们使用菜单中的 Tools->Developer->New Plugin... 来创建一个新插件文件。插件可以使用 Python 的各种标准库以及 Sublime 提供的 各种 API

开发插件需要了解 Sublime 中的几个重要概念:

  • Window: 很好理解,就是一个 Sublime 窗口
  • Sheet: 基本上等于 Tab,就是每一个标签页
  • View: 相当于 Vim 中的 Buffer,表示每一个被编辑的文本
  • Region: 一个连续的区域
  • Selection: 当前选中的内容,由一或多个 Region 构成,因为 Sublime 允许多个光标,所以需要区分 Region 和 Selection

Hello World Plugin

现在我们来编写一个 Hello World 插件感受一下 Sublime 的插件开发。我们要实现的功能很简单,按下 ctrl+-,在当前光标位置插入 Hello, World!

首先,使用菜单新建一个插件,保存为 hello.py。Sublime 默认填充的内容如下:

import sublime
import sublime_plugin

class ExampleCommand(sublime_plugin.TextCommand):
  def run(self, edit):
    self.view.insert(edit, 0, "Hello, World!")

Sublime 提供了几个基础类用于我们继承来实现一些基本功能,比如 TextCommand 用于实现文本操作,EventListener 实现事件监听。

这个默认插件定义了 ExampleCommand,每次运行时,调用 view 的 insert 方法插入 Hello, World! 字符串到 0 位置,也就是最前面。

定义了 Command 以后,我们绑定一个快捷键到这个 Command 就可以运行它。在 Command Paletee,输入 Preferences: Key Bindings 打开快捷键配置,输入以下配置:

{
  "keys": ["ctrl+-"],
  "command": "example"
}

注意,Command 的名字是 camel_case,而 Class 的名字是 PascalCase,同时不用带最后面的 Command。比如类的名字是 MyAwesomeCommand,那么配置快捷键时对应的 Command 名字是 my_awesome 就行了。

配置好以后我们的插件就可以工作了,按下 ctrl+- 会发现最前面插入了 Hello, World!

但这离我们要的效果还差了一点,我们的目标是在当前光标位置插入内容,而不是在文件的最前面。

很明显,我们要修改 view.insert 方法的第二个参数,这个参数不用看文档也可以猜到表示的是插入内容的位置。

所以,现在任务变成了如何获取当前光标的位置。查看 Sublime 文档会发现,view.sel() 方法会返回当前 View 的 Selection 对象,根据 Selection 对象可以获取到 Region 对象,根据 Region 对象就可以获取到位置的偏移量。

光标在闪烁表示当前没有选中任何东西,也就是 Region 的 start 和 end 偏移量是相同的。我们根据这个条件来检查当前是否是光标状态还是选中状态,插件只有在光标状态才工作。

使用这些知识,我们就可以来改造这个插件了,代码很简单就不多说了。

import sublime
import sublime_plugin

class ExampleCommand(sublime_plugin.TextCommand):
  def run(self, edit):
    selections = self.view.sel()
    if len(selections) == 1 and selections[0].begin() == selections[0].end():
      self.view.insert(edit, selections[0].begin(), "Hello, World!")

BuildX

现在我们来看看怎样实现一个插件实现我想要的 构建、侧边显示错误信息、快速跳转错误行 的功能。我给这个插件起名叫做 BuildX。

首先,Sublime 是自带构建系统的,叫做 Build System,是一个很好用的工具,我们可以使用它来构建任意项目,配置很简单,这里不再展开。

Sublime 的构建系统默认会输出内容到底部的 Exec Panel,看起来很难受。平时我写代码,编辑器都会开两列,方便互相对照,我希望构建以后能在侧边看到输出信息而不是要低头去底部看,就像上面的 Emacs 那样。

我们的插件最终效果如下,构建以后,侧边显示输出信息,同时可以快速跳转到报错的行。

参考了 sublime text 2 buildview 的实现,插件的设计思路是:

  • 监听 Sublime 的构建,Sublime 会输出内容到底部的 Exec Panel
  • 监听 Exec Panel 的 on_modified 事件
  • 如果没有 Target View,创建一个 Target View 用于显示构建的输出
  • 读取 Exec Panel 中的内容到 Target View

监听构建

首先,我们需要监听 Sublime 的构建。这里用了一个比较 Tricky 的手段,构建实际上是在运行自带的 build Command,但是 Sublime 没有提供方法让我们监听这个 Command。

所以换一个思路,我们监听这个 Command 对应的快捷键是否按下了。Sublime 中每个快捷键都可以带上 Context,插件中可以实现一个方法叫做 on_query_context 然后动态返回是否要触发对应的 Command。这样设计的好处在于同样的快捷键在不同的场景中可以运行不同的 Command。

通过“滥用”这个技术,我们可以监听到某个快捷键是否按下。具体做法是配置一个快捷键用于触发构建,同时带上一个 Context,然后在 EventListener 的 on_query_context 方法中判断是否存在这个 Context,如果存在,我们就知道快捷键按下了,也就是说,构建开始了。

快捷键配置:

[
  {
    "keys": ["super+b"],
    "command": "build",
    "context": [{"key": "for_buildx", "operator":"equal", "operand":true}]
  },
]

BuildX 插件:

class BuildXListener(sublime_plugin.EventListener):
  def on_query_context(self, view, key, *args):
    if key != 'for_buildx':
      # 取消执行快捷键对应的 Command
      return None

    # 此时我们知道构建开始了
    return True

监听 Exec Panel

通过 Window 的 get_output_panel 方法我们可以获取到 Exec Panel 对应的 View,然后就可以使用 EventListener 去监听这个 View 是否修改了。

如果这个 View 的 on_modified 出发了,说明构建内容正在输出。

class BuildXListener(sublime_plugin.EventListener):
  source_view = None

  def on_modified(self, view):
    if self.source_view is None:
      return

    if self.source_view.id() != view.id():
      return

    # 到了这里说明 Exec Panel 的内容更新了

  def on_query_context(self, view, key, *args):
    if key != 'for_buildx':
      return None

    self.source_view = view.window().get_output_panel('exec')

    return True

创建 Target View

得到构建内容以后,我们要做的就是输出到我们的 Target View 中去,在此之前,我们先要创建这个 View。

这一步很简单,调用 Windownew_file 函数就可以得到一个 View,同时注意设置它的属性为 Scratch,也就是说,关闭它的时候 Sublime 不会提醒你保存它。

class BuildXListener(sublime_plugin.EventListener):
  source_view = None
  target_view = None
  window = None

  def on_modified(self, view):
    if self.source_view is None:
      return

    if self.source_view.id() != view.id():
      return

    # 创建 target view
    if self.target_view is None:
      self.target_view = self.window.new_file()
      self.target_view.set_name('Build Output')
      self.target_view.set_scratch(True)

  def on_query_context(self, view, key, *args):
    if key != 'for_buildx':
      return None

    self.window = view.window()
    self.source_view = view.window().get_output_panel('exec')

    return True

这样每次构建时,如果没有 Target View,就会新建一个。

拷贝 Exec Panel 中的内容

最后,我们要做的就是在 on_modified 的时候拷贝内容到我们的 Target View 中。

首先,我们通过 view.substr 函数读取到 Exec Panel 中的内容,然后使用 view.replace 函数将该内容写到 Target View 中。

因为 view.replace 需要一个 Edit 对象,所以我们需要创建一个 TextCommand 来执行这个方法。

因为 on_modified 可能会触发多次,所以我们需要一个变量来记录一下最后一次写的位置是什么。

import sublime
import sublime_plugin

class ContentReplace(sublime_plugin.TextCommand):
  def run(self, edit, start, end, text):
    self.view.replace(edit, sublime.Region(start, end), text)

class BuildXListener(sublime_plugin.EventListener):
  source_view = None
  target_view = None
  window = None
  last_pos = 0

  def on_modified(self, view):
    if self.source_view is None:
      return

    if self.source_view.id() != view.id():
      return

    # 创建 target view
    if self.target_view is None:
      self.target_view = self.window.new_file()
      self.target_view.set_name('Build Output')
      self.target_view.set_scratch(True)

    # 复制内容到 target view
    new_pos = view.size()
    region = sublime.Region(self.last_pos, new_pos)
    content = view.substr(region)
    self.target_view.run_command('content_replace', {'start': self.last_pos, 'end': new_pos, 'text': content})

  def on_query_context(self, view, key, *args):
    if key != 'for_buildxy':
      return None

    self.window = view.window()
    self.source_view = view.window().get_output_panel('exec')

    return True

到了这里,核心功能就已经有了,构建的输出可以在 Target View 中显示,从现在开始我们就可以摆脱底部的 Exec Panel 了。

最后

当然插件离最后可用还有一些细节要打磨,比如

  • 每次构建时需要清除 Target View 中上次输出的东西
  • 每次构建时需要将 Target View 放置在当前 View 的侧边
  • 每一个窗口都需要有一个对应的 Target View
  • 如果内容过长,要滚动 Target View

这些对着文档都很容易,不再赘述了,有兴趣的朋友可以自己尝试一下怎样实现,是一个很好的练手机会。

关于错误行跳转功能,Sublime 是自带的,点击菜单 Build Results->Next Result/Previous Result 就可以使用,前提是 Build System 中的 file_regex 正则要配置好。

每次跳转的时候,Sublime 会在 Exec Panel 中高亮当前所在的错误信息(具体实现是选中那些信息,选中的文本会有不一样的样式,也就实现了高亮),通过监听 Exec Panel 的 on_selection_modified 的事件,我们就可以在 Target View 中实现高亮。

我最后完成的代码在这里 sublime-buildx。内容很简单,没有什么复杂的,但是实现的这个功能对我来说却大大提高了生产力。我想如果对 Sublime 的 API 有了解的人,实现这样的插件应该花不了两个小时。

两个小时就可以解决的问题,为什么要推翻整个系统重来?之后任何的重大动作都要仔细评估是否值得。

XKCD 的这幅漫画很能引发思考,在你动手 切换工具/造工具/改进工具 的时候,一定要评估一下你投入的时间以及节省的时间是否匹配。

如果说一个任务你每天做一次,每次耗费 6 分钟,你觉得很慢,动手改进,改进到了 1 分钟,那么这次改进带来的提升是节省了 5 分钟,以五年为例,一共为你节省了 6 天。问题是,将一个工具从 6 分钟改进到 1 分钟如此巨大的性能提升 6 天内你能搞定吗?如果搞不定的话,就要好好想想了。