首发于Vim

Vim 8 中的 C/C++ 编译运行:类 vscode 的任务系统

谦卑的向大家介绍我的新插件:asynctasks.vim,一套类似 vscode 的 tasks 系统,用于解决 vim 下长期没法轻松优雅的编译/运行 C/C++ 程序的问题。这个插件我去年酝酿了很长时间了,今年打算给他做一点宣传。

最近两年 Vim/NeoVim 发展非常迅速,各种:异步补全/LSP/查错,DAP 等项目相继出现,就连 vimspector 这样以前只能奢望 emacs 的项目如今都出现了。

然而 Vim 任然缺少一套优雅的通用的任务系统来加速你的内部开发循环(编辑,编译,测试)。很多人在处理这些 编译/测试/部署 类任务时,任然还在使用一些比较原始的方法,所以我创建了这个插件,将 vscode 的任务系统引入 Vim。

vscode 为每个项目的根目录下新建了一个 .vscode 目录,里面保存了一个 tasks.json 配置文件来描述针对该项目的各类:编译/运行任务。而 asynctasks.vim 采用类似的机制,在每个项目的根文件夹下面放一个 .tasks 配置文件来描述针对该项目的任务,同时维护一份 ~/.vim/tasks.ini 的全局配置,适配一些通用性很强的项目,避免每个项目重复写 tasks 配置。

说起来好像很简单?其实这是概念简单,很多好的设计从概念上来讲往往非常简单,但是用起来却十分灵活强大,这不是我设计的好,而是 vscode 的 tasks 系统设计的好,我只是大自然的搬运工,这应该是目前 Vim 下最强的构建工具,下面就试用一下:

单个文件的编译运行

我经常写一些小程序,验证一些小想法,那么在不用创建一个庞大工程的情况下,直接编译和运行单个文件就显得很有用,我们运行 :AsyncTaskEdit 命令,就能编辑当前项目或者当前目录的 .tasks 配置文件:

[file-build]
# 定义任务需要执行的命令,以 `$(...)` 形式出现的宏会在执行时被具体替换
command=gcc -O2 "$(VIM_FILEPATH)" -o "$(VIM_FILEDIR)/$(VIM_FILENOEXT)"
# 定义命令运行的目录
cwd=$(VIM_FILEDIR)

[file-run]
command="$(VIM_FILEDIR)/$(VIM_FILENOEXT)"
cwd=$(VIM_FILEDIR)
# 定义输出方式,在终端内运行
output=terminal

这里定义了两个任务:file-buildfile-run 在包含这个 .tasks 配置文件的目录及其子目录下面任意一个文件,都可以用:

:AsyncTask file-build
:AsyncTask file-run

两条命令来分别编译和运行它:

上图是运行 :AsyncTask file-build 的效果,默认模式下(output=quickfix),命令输出会实时显示在下方的 quickfix 窗口中,编译错误会和 errorformat 匹配并显示为高亮,方便你按回车跳转到具体错误,或者用 cnext/cprev 命令快速跳转错误位置。

配置文件中有丰富的宏,可以在运行时被替换成实际的文件,或者当前项目信息,这样直接敲命令肯定不够高效,我们绑定到 F5 和 F9 上:

noremap <silent><f5> :AsyncTask file-run<cr>
noremap <silent><f9> :AsyncTask file-build<cr>

在你的 vimrc 中加入上面两句,就能按 F9 编译当前文件,F5 运行它了, 到这里你可能会说,这是 C/C++ 啊,如果我想运行 Python 代码怎么办呢?重新写个任务?不用那么麻烦,command 字段支持文件类型过滤:

[file-run]
command="$(VIM_FILEPATH)"
command:c,cpp="$(VIM_PATHNOEXT)"
command:go="$(VIM_PATHNOEXT)"
command:python=python "$(VIM_FILENAME)"
command:javascript=node "$(VIM_FILENAME)"
command:sh=sh "$(VIM_FILENAME)"
command:lua=lua "$(VIM_FILENAME)"
command:perl=perl "$(VIM_FILENAME)"
command:ruby=ruby "$(VIM_FILENAME)"
output=terminal
cwd=$(VIM_FILEDIR)
save=2

只需要在 command 字段后面加冒号,写明匹配的文件类型就行, 匹配不到的话就会使用最上面的默认命令来执行,注意文件名可能包含空格,所以要双引号,最后加了个 -save=2 可以在运行前保存所有改动的文件。

这样简单配置一下,你就能统一的用 F5 运行所有类型的文件了,这下你可以立马把 quickrun 这样的插件卸载掉了,它做的事情还没有上面这几行做的漂亮。接下来我们继续配置 F9 ,根据文件类型调用编译器:

[file-build]
command:c,cpp=gcc -O2 -Wall "$(VIM_FILEPATH)" -o "$(VIM_PATHNOEXT)" -lstdc++ -lm -msse3
command:go=go build -o "$(VIM_PATHNOEXT)" "$(VIM_FILEPATH)"
command:make=make -f "$(VIM_FILEPATH)"
output=quickfix
cwd=$(VIM_FILEDIR)
save=2

这适配了三种类型的文件,C/C++,Go,以及 Makefile,按下 F9 就可以根据当前文件类型执行对应的构建命令,并且把输出显示到 quickfix 窗口中,进行错误匹配。

上面的配置你既可以放在某个目录下,作用于所有下级目录也可以放到全局配置中,整个系统起作用。比你配置什么 makeprg 或者 vimscript 写一大堆乱七八糟的 if else 文件类型判断,和 asyncrun/neomake 调用优雅很多。

这里我们看到编译类项目一般配置 output=quickfix (默认值,不写也一样)这样可以将编译输出显示到 quickfix 窗口进行匹配,而运行类项目一般设置 output=terminal 选择终端模式,终端模式下有很多不同的运行方式,比如:内置终端,外置终端,quickfix模拟终端,经典 ! 指令,tmux 分屏等,后面会说怎么指定 output=terminal 时的运行方式。

整个项目的编译运行

仅有单个文件的编译运行是不够的,大部分时候我们是工作在一个个项目中,很多 vim 插件解决单个文件编译运行还行,但是项目级别的编译运行就相形见拙了。而 asynctasks.vim 在这个问题上应该是同类插件中做的最好的。

解决项目编译运行首先需要定位项目目录,在 Vim 中,众多插件也早就采用了一套叫做 rootmark 的机制, 从当前文件所在目录一直往上递归到根目录,直到发现某一级父目录中包含下列项目标识:

let g:asyncrun_rootmarks = ['.git', '.svn', '.root', '.project', '.hg']

则认为该目录是当前项目的根目录,如向上搜索到根目录都没找到任何标识,则将当前文件所在目录当作项目根目录。

如果你的项目在版本管理系统里,那么仓库的顶层文件夹就会被自动识别成项目的根目录,而如果你有一个项目既不在 git 中,又不在 svn 中怎么办?或者你的 git/svn 的单个仓库下面有很多项目,你并不想让最上层作为项目根目录的话,你只要在你想要的地方新建一个空的 .root 文件就行了。

最后一个边界情况,如果你没有打开文件(未命名新文件窗口),或者当前 buffer 是一个非文件(比如工具窗口),怎么办呢?此时会使用 vim 的当前文件夹(即 :pwd 返回的值)作为项目目录。

这基本是一套多年下来行之有效的约定了,众多插件都采用这个方法确定项目位置,比如大家熟知的:YCMAsyncRunCtrlPLeaderFcclsGutentags 等等。vscode 也采用类似的方法在项目顶层放置一个隐藏的 .vscode 文件夹,来标记项目根目录。

有了项目位置信息后我们就可以在任务中用 $(VIM_ROOT) 或者 <root> 来代替项目位置了:

[project-build]
command=make
# 设置在当前项目的根目录处运行 make
cwd=$(VIM_ROOT)

[project-run]
command=make run
# <root> 是 $(VIM_ROOT) 的别名,写起来容易些
cwd=<root>
output=terminal

我们把这两个任务分别绑定到 F6 和 F7 上面:

noremap <silent><f6> :AsyncTask project-run<cr>
noremap <silent><f7> :AsyncTask project-build<cr>

那么我们就能轻松的使用 F7 来编译当前项目,而 F6 来运行当前项目了。那么也许你会问,上面定义的都是用 make 工具的来编译运行啊,我的项目不用 make 构建怎么办?项目又不能根上面单个文件那样通过单个文件类型来区分 command,难道我要把不同构建类型的项目定义很多个不同的 task,搞一大堆类似 project-build-cmakeproject-make-ninjia ,然后在 F1-F12 上绑定满它们吗?

配置优先级

并不需要,最简单的做法是你可以把上面两个任务(project-buildproject-run)配置成公共任务,放到 ~/.vim/tasks.ini 这个公共配置里,然后对于所有一般的 make 类型项目,你就不用配置了。

而对于其他类型的项目,比如某个项目中,我还在用 msbuild 来构建,我就单独给这个项目的 .tasks 局部配置中,再定义两个名字一模一样的局部任务,比如项目 A 中:

[project-build]
command=vcvars32 > nul && msbuild build/StreamNet.vcxproj /property:Configuration=Debug /nologo /verbosity:quiet
cwd=<root>
errorformat=%f(%l):%m

[project-run]
command=build/Debug/StreamNet.exe
cwd=<root>
output=terminal

asynctasks.vim 中,局部配置的优先级高于全局配置,下层目录的配置高于上层目录的配置(.tasks 可以嵌套存在)。因此,在 A 项目中,老朋友 project-buildproject-run 两个任务被我们替换成了针对 A 项目的 msbuild 的方法。

先调用 vcvars32.bat 初始化 vc 环境,然后用 && 符号连接 msbuild 命令行,这样在 A 这个项目中,我任然可以使用 F7 来编译项目,然后 F6 来运行整个项目,不会因为项目切换而导致我的操作发生改变,我可以用统一一致的操作,处理各种不同类型的项目,这就是本地任务和全局任务协同所能产生的奇迹。

可用任务查询

那么当前项目下,到底有些什么可用任务呢?他们到底是局部还是全局的?一个任务到底最终是被什么配置文件给 override 掉了?我们用 :AsyncTaskList 命令可以查看:

该命令能显示可用的 task 名称,具体命令,以及来自哪个配置文件。

宏变量展开

前面任务配置里,用到了几个形状如同 $(VIM_xxx) 的宏,具体在运行时会具体替换成对应的值,常用的宏有:

$(VIM_FILEPATH)    # 当前 buffer 的文件名全路径
$(VIM_FILENAME)    # 当前 buffer 的文件名(没有前面的路径)
$(VIM_FILEDIR)     # 当前 buffer 的文件所在路径
$(VIM_FILEEXT)     # 当前 buffer 的扩展名
$(VIM_FILENOEXT)   # 当前 buffer 的主文件名(没有前面路径和后面扩展名)
$(VIM_PATHNOEXT)   # 带路径的主文件名($VIM_FILEPATH 去掉扩展名)
$(VIM_CWD)         # 当前 Vim 目录(:pwd 命令返回的)
$(VIM_RELDIR)      # 相对于当前路径的文件名
$(VIM_RELNAME)     # 相对于当前路径的文件路径
$(VIM_ROOT)        # 当前 buffer 的项目根目录
$(VIM_CWORD)       # 光标下的单词
$(VIM_CFILE)       # 光标下的文件名
$(VIM_CLINE)       # 光标停留在当前文件的多少行(行号)
$(VIM_GUI)         # 是否在 GUI 下面运行?
$(VIM_VERSION)     # Vim 版本号
$(VIM_COLUMNS)     # 当前屏幕宽度
$(VIM_LINES)       # 当前屏幕高度
$(VIM_SVRNAME)     # v:servername 的值
$(VIM_DIRNAME)     # 当前文件夹目录名,比如 vim 在 ~/github/prj1/src,那就是 src
$(VIM_PRONAME)     # 当前项目目录名,比如项目根目录在 ~/github/prj1,那就是 prj1
$(VIM_INIFILE)     # 当前任务的 ini 文件名
$(VIM_INIHOME)     # 当前任务的 ini 文件的目录(方便调用一些和配置文件位置相关的脚本)

上面这些宏基本够你日常使用了,除了替换 commandcwd 配置外,同名的环境变量也被设置成同样的值,例如你某个任务命令太复杂了,你倾向于写道一个 shell 脚本中,那么 command 配置就可以简单的调用一下这个 xxx.sh 文件:

[project-build]
command=build/my-build-task.sh
cwd=<root>

根本不用传参,这个 my-build-task.sh 脚本本内部直接用 $VIM_FILENAME 这个环境变量就能取出文件名来,这样通过环境变量传递当前项目/文件信息的方法,结合外部脚本,能让我们定义各种相对复杂的任务,比直接裸写几行 vimscript 的 keymap 强大灵活多了。

那么当前这些宏到底会被展开成什么呢?我们可以通过 :AsyncTaskMacro 命令查看:

左边是宏名称,中间是说明,右边是具体展开值。这条命令很有用,当你写 task 配置忘记宏名称了,用它随时查看,不用翻文档。

多种运行模式

配置任务时,output 字段可以设置如何运行任务,它有下面两个值:

  • quickfix: 默认值,实时显示输出到 quickfix 窗口,并匹配 errorformat。
  • terminal:在终端内运行任务。

第一个自然没啥好说,当设置为第二个 terminal 时,还可以通过一个全局变量:

let g:asynctasks_term_pos = '...'

来具体设置终端的工作位置和工作模式,它有几个可选值:

  • quickfix:默认值,使用 quickfix 窗口模拟终端,输出不匹配 errorformat
  • vim:传统 vim 的 ! 命令运行任务,有些人就是迷恋这种方式。
  • tab:内置终端,在一个新的 tab 上打开内置终端,运行程序。
  • top:内置终端,在上方打开可复用内部终端。
  • bottom:内置终端,在下方打开可复用内部终端。
  • left:内置终端,在左边打开可复用内置终端。
  • right:内置终端,在右边打开可复用内置终端。
  • external: 外置终端,启动一个新的操作系统的外置终端窗口,运行程序。

除了指定全局的 g:asynctasks_term_pos 外,在任务配置文件中,也可以用 pos=? 来强制指定该任务需要何种方式运行。

基本上 Vim 中常见的运行模式都包含了,选择一个你喜欢的模式即可,见到那演示一下:

output=terminal 时,设置:

let g:asynctasks_term_pos = 'bottom'

那么运行 :AsyncTask file-run 时,就能在下方的内置终端运行任务了:

终端窗口会复用,如果上一个任务结束了,再次运行时不会新建终端窗口,会先尝试复用老的已结束的终端窗口,找不到才会新建。当使设置为 top/bottom/left/right 时,可以用下面两个配置确定终端窗口大小:

let g:asynctasks_term_rows = 10    " 设置纵向切割时,高度为 10
let g:asynctasks_term_cols = 80    " 设置横向切割时,宽度为 80

有人说分屏的内置终端太小了,没关系,你可以设置成 `tab`:

let g:asynctasks_term_pos = 'tab'

这样基本就能使用整个 vim 全屏大小的区域了:

整个 tab 都用于运行你的任务,应该足够大了吧?这是我个人比较喜欢的方式。

(PS:内置终端有时候需要调教一下才会比较顺手,我在《内置终端调教记》里推荐过一些 keymap ,这里鼓励大家使用 ALT+HJKL 来进行窗口切换,淘汰老旧的 CTRL+HJKL,再使用 ALT+q 来返回终端 NORMAL 模式,这几个 keymap 我用到现在都非常顺手。)

默认的 quickfix 模式尽管也可以运行程序,但是并不适合一些有交互的任务,比如需要用户输入点什么,quickfix 模式就没办法了,这时你就需要一个真实的终端,真实终端还能正确的显示颜色,这个在 quickfix 是无能为力的事情。

当然,内置终端到 vim 8.1 才稳定下来,处于向下兼容,asynctasks 默认使用 quickfix 模式来运行任务,即用 quickfix 模拟终端,输出不匹配 errorformat,直接原始输出。根据需要修改这个 g:asynctasks_term_pos 的值就行了。

外部终端

在 Windows 下经常使用 Visual Studio 的同学们一般会喜欢像 VS 一样,打开一个新的 cmd 窗口来运行程序,我们设置:

let g:asynctasks_term_pos = 'external'

那么对于所有 output=terminal 的任务,就能使用外部系统终端了:

是不是有点 VS 的感觉了?基本可能的运行方式都有了。

还有很多高级的用法和各种细节设置,详细见项目文档:

github.com/skywind3000/


后话

我看过很多教你在 Vim 中搭建开发环境的教程,他们介绍的都很全:移动加速,补全,LSP,文件搜索,定义引用查询,主题修改,但是大部分都遗漏了如何编译/运行 C/C++ 这个最基本的事情,原因很简单,因为之前在 Vim 下确实没有一套优雅的方法来完成这件事情。

所以在我读过的大部分教程或者 vimrc 里面,看到别人都是在采用一些相对原始的方式。而从现在起,真的再也不用选择过去种种粗糙的方法来编译运行项目了。虽然多年养成的使用习惯让人不是那么容易改变自己的工作流,但好在 vim 用户都比较喜欢折腾,只要能优化效率能更优雅整洁,把以前的工作流推翻了又何妨呢?


--

扩展阅读:

编辑于 2020-02-18

文章被以下专栏收录

    写一些从想法到脚本实现的详细过程,我希望更多的用户可以自己写脚本

    最前沿的Vim资讯,实用的Vim配置技巧,操作技巧,插件推荐.