DarkMatter in Cyberspace
  • Home
  • Categories
  • Tags
  • Archives

Send Codes in Vim to REPL


使用 Python, Haskell, Ruby 的 REPL 时经常需要能够在一个文本文件中将 当前行或者选中多行文本发送到 REPL 中运行, 并能够灵活地在文本编辑器(执行编辑好的脚本)和 REPL (手工输入并执行一些一次性的代码)间切换。

实现方法总体来说有两种,第一种使用 vim 自己的 terminal, 例如下面的 neovim 自带的 terminal, 第二种是将 editor 和 repl 包裹在 tmux 里。 第一种好处是集成度高,只要 vim 就行了, 缺点是 editor 和 repl 必须在同一台机器上。 第二种优缺点正好相反,需要手工启动 REPL(或者先登录远程主机再启动 REPL), 但比前一种更灵活,适合的场景更广泛。

下面的几种方法使用不同的工具实现了下列目标:

  • :tn: 创建当前脚本语言对应的 REPL;

  • :tl: 将当前脚本加载到 REPL 中执行;

  • :tg: 显示/隐藏 REPL 窗口;

  • 使用空格键发送当前行,或者选中行到 REPL 中执行;

  • :tt <your command>: 在 REPL 中执行 <your command>;

基于 neoterm 的方法的优点是不依赖 vim 以外的工具,适合在不能使用 tmux 的场合 (例如使用 i3wm 时由于快捷键冲突无法使用 tmux)。

Based on neoterm

Add the following lines into $MYVIMRC:

let g:neoterm_default_mod = 'vertical'
let g:neoterm_autoscroll = 1
let g:neoterm_direct_open_repl = 1
let g:neoterm_repl_ruby = 'pry'
let g:neoterm_repl_python  = 'ipython'
autocmd FileType haskell let g:neoterm_repl_command  = 'stack ghci'

autocmd FileType haskell,python,ruby nnoremap <silent> <Space> :TREPLSendLine<CR>
autocmd FileType haskell,python,ruby vnoremap <silent> <Space> :TREPLSendLine<CR>

autocmd FileType haskell,python,ruby cabbrev tt T
autocmd FileType haskell,python,ruby cabbrev tn Tnew
autocmd FileType haskell,python,ruby cabbrev tg Ttoggle
autocmd FileType haskell cabbrev tl T :load %
autocmd FileType python cabbrev tl T run %
autocmd FileType ruby cabbrev tl T load '%'

Plug 'kassio/neoterm'

With above configurations, after open a python or haskell file and press key, for there's no existing terminal window existing, neoterm will create a new one automatically and send the current line into it. Or you can select multiple lines (in visual mode) and send to the terminal window with key.

Reload a file with :tl.

After the job is done, no need to quit with Tclose, just :qa.

If you want input something on-the-fly in the REPL, you have 2 options:

  • :tt :set +m and press Return key. :tt and pressing becomes :T. All characters following is sent to REPL. This is convenient, while you lose the tab-completion functionality in REPL.

  • Jump to the REPL window, switch to insert mode with pressing i, input something (for example filter even [1,2,3]), leave insert mode with pressing Ctrl-\ Ctrl-n, finally jump back to editor window.

Note: if you want open the terminal window in the bottom instead of right, use let g:neoterm_default_mod = 'botright'. See :h mods in vim for more options.

Under the hood

If above shortcuts don't work, restart the vim. Or you can run it manually:

  1. Open a new terminal window with :sp | terminal

  2. Get terminal window ID with :echo b:terminal_job_id

  3. Run a command, say ls -la, with :call jobsend(7, "ls -la\n"), where the first parameter 7 of jobsend is from above step.

Paste mode in GHCi

When send the following lines into repl with neoterm, there are always errors at the line where ...:

drink aCup ozDrank = if ozDiff >= 0
                     then cup ozDiff
                     else cup 0
  where oldOz = getOz aCup
        ozDiff = oldOz - ozDrank

The reason is GHCi believe the block is ended after else ..., even you've added :set +m.

The solution is add :{ (with :ts :{) and :} before and after the block. See How to define a function in ghci across multiple lines?.

Based on vim plugin vimux

vimux 能够自动检测tmux pane, 基于它的工作流程是:

为vim安装vimux插件并配置好快捷键(例如下面的配置将快捷键设为空格键)后, 在一个 tmux window 中,左/上侧是vim窗口,右/下侧是repl,例如IPython或者GHCi, Normal模式下按空格键,将当前行发送到repl中执行, Visual模式下按空格键,选中的所有文本被整体发送到 IPython 的 %cpaste 区域中 一次性执行。

具体配置方法:

function! Vimux4IPython()
  call VimuxRunCommand("%cpaste")
  call VimuxRunCommand(@v, 0)
  call VimuxRunCommand("^d")
endfunction

nnoremap <Space> :call VimuxRunCommand(getline("."))<CR>
autocmd FileType python vnoremap <Space> "vy :call Vimux4IPython()<CR>
"autocmd FileType python vnoremap <Space> "vy :call VimuxRunCommand("%cpaste")<CR> :call VimuxRunCommand(@v, 0)<CR> :call VimuxSendKeys("^d")<CR>

Plug 'benmills/vimux'

vnoremap 将空格键映射到后面的命令上,这个命令包含两部分:

  • 用 "vy 将选中的文本拷贝到 v register 上
  • 调用 Vimux4IPython() 向 IPython console 发送命令

VimuxRunCommand 函数的第一个参数是要执行的命令, 第二个参数是执行完 tmux send-keys 后是否再发送一个回车, 所以 VimuxRunCommand(@v, 0) 的第二个参数 0 保证了发送多行文本中间不会插入回车。

VimuxRunCommand() 底层调用了 tmux send-keys, 所以需要发送 alt, ctrl 等时参考 tmux send-keys 的写法。

实验发现 VimuxSendText 和 VimuxSendKeys 似乎是异步的, 不能保证前面的命令执行完后再执行后面的命令,导致执行出错, 所以全部使用了 VimuxRunCommand 命令,后来发现这个命令也会出现 "^d" 在 @v 前被发送的情况, 下面被注释掉的目前可以保证顺序执行。

完整内容参考 vem 的 profiles/python.yml。

Note:

另一个执行多行脚本的思路是基于 %autoindent 关闭 IPython console 的代码自动缩进, 然后完全模拟多行输入,这个方案的问题是 %autoindent 只能在 on/off 间切换, 不能用 %autoindent 0 之类的方法保持关闭状态,导致多次执行代码块时无法确定 autoindent 是否关闭,可以用 ipython --no-autoindent 等方法保持其始终关闭的状态, 在 IPython 里手工输入时,再手工开启,麻烦且容易出错。

指定 REPL 运行窗口

先执行 :let g:VimuxRunnerIndex='3.1',再空格。

vimux 代码分析

vimux 全部代码位于 plugin/vimux.vim 中,代码量比较小。

执行命令 (VimuxRunCommand) 流程:

  1. 若未定义 g:VimuxRunnerIndex, 或者 g:VimuxRunnerIndex 值对应的 tmux 窗口不存在, 则通过执行 VimuxOpenRunner 确定 g:VimuxRunnerIndex 的值(详细过程见下面);

  2. 若函数调用包含了第二个参数 (exists("a:1")) 则在发送完命令后在发送回车 (VimuxSendKeys("Enter"))

  3. 定义复位指令,将当前命令记录在 g:VimuxLastCommand 中, 供后续执行 VimuxRunLastCommand 命令调用;

  4. 发送 tmux 复位指令(取消未发送完的命令):call VimuxSendKeys(resetSequence)

  5. 发送用户指定的命令文本:call VimuxSendText(a:command)

确定 REPL runner (VimuxOpenRunner) 流程:

  1. 获取最近的 REPL 窗口(下面详述);

  2. 若要求使用最近窗口(_VimuxOption("g:VimuxUseNearest", 1) == 1) 且此窗口存在(nearestIndex != -1), 则将 REPL runner 定义为此窗口(let g:VimuxRunnerIndex = nearestIndex);

  3. 否则根据 g:VimuxRunnerType 的值新建一个 pane (tmux split-pane -p ...) 或者 window (tmux new-window) 作为 REPL runner

获取最近的 REPL 窗口(_VimuxNearestIndex):

  1. 列出当前 window 中的所与 panes: _VimuxTmux("list-"._VimuxRunnerType()."s"), 实际上执行了 tmux list-panes,因为 g:VimuxRunnerType 默认值为 'pane';

  2. 若所有 pane 均为 active(实际上是只有一个 active pane,for 循环只运行一次), 则返回 -1,否则返回第一个不为 active 的 pane 的标识符 x.y;

Note:

根据上面确定 REPL runner 的规则,不指定 g:VimuxRunnerIndex 的情况下, REPL pane 可以在 vim pane 的左侧或者上面。

vimscript 中调用函数有两种方法:直接执行函数时要加 call, 用于其他函数的参数(最常见场景是打印函数返回值 :echo myfunc()) 或者表达式时不加 call。

Vimscript functions 查询 vimscript 函数很方便。

Based on Tmux send-keys

与 vimux 的原理相同,也是在 tmux 中利用 send-keys 命令将当前或者选中的文本 发送到目标 pane 中。

目录结构:当前工作目录下有 sendcmd.vim 和 test.md 两个文件, 前者包含实现功能的 vim 函数,后者是包含要执行命令行的数据文件。

开发环境:tmux window 1 做sendcmd.vim开发,window 2 上下拆分为两个pane ,上面 (ptop) 是命令行,下面的 pane (pbottom) 中用 vim 打开数据文件test.md.

每次修改并保存sendcmd.vim后,在test.md中执行:so sendcmd.vim, 然后把光标移动到要执行的命令所在的行上按F3键就可以看到命令被发送到ptop里的 执行效果了。

格式探测

这一步要解决的问题是:怎样的字符串,才能被tmux send-keys正确地传送出去, 并能正确的执行。

首先要保证运行tmux send-keys不报错,如果报错, 将第5行的输出拷贝到一个单独的命令行中运行,解决错误。

这一步通过后,如果命令被传送到ptop后报错,根据错误日志解决之。

sendcmd.vim:

function! SendCmd()
  let curline = getline(".")
  echom curline
  let cmd = "tmux send-keys -t top '" . curline . "' Enter"
  echom cmd
  echom system(cmd)
endfunction

nnoremap <F3> :call SendCmd()<CR>

说明:

  • 为什么第4行中curline要用单引号(而不是双引号)包裹?

因为要执行的命令中可能包含变量(如es, idx等),双引号包裹的变量会被求值, 而我们的要求是这些变量不能被求值,要等到被发送到ptop后再被求值。由于这个原因, 第4行包裹外层tmux send-keys的命令就只能用双引号包裹。

  • vimscript如何连接字符串?

同样参考第4行,用点( . )连接。

  • tmux send-keys -t top 可以将keys从下面的pane发给上面, 也可以从左侧发到右侧,但如果是在右侧pane里执行这个语句,则会被发给自己。

test.md:

es=http://192.168.100.231:9200
api=http://192.168.100.231:8000
idx=production
type=Fair

# get elasticsearch version
http $es | jq '.version.number'

# list all indices
http -b GET $es'/_cat/indices?v'
http -b GET $es'"'"'/_cat/indices?v'"'"'

# list all types of a index
http -b GET $es/$idx/_mapping|jq ".$idx.mappings|keys"

# list all properties of a type
http -b GET $es/$idx/_mapping|jq ".$idx.mappings.$type.properties|keys"

# get objects count in a type
http -b GET $es/$idx/$type/_count|jq '.count'
http -b GET $es/$idx/$type/_count|jq '"'"'.count'"'"'

# query result count
http -b POST $es/$idx/$type/_search query:='{"bool":{"must":[{"query_string":{"query":"五金机械"}}]}}' | jq '.hits.total'
http -b POST $es/$idx/$type/_search query:=''{"bool":{"must":[{"query_string":{"query":"五金机械"}}]}}''
http -b POST $es/$idx/$type/_search query:='"'"'{"bool":{"must":[{"query_string":{"query":"五金机械"}}]}}'"'"'
http -b POST $es/$idx/$type/_search query:='"'"'{"bool":{"must":[{"query_string":{"query":"五金机械"}}]}}'"'"' | jq '"'"'.hits.total'"'"'

通过不断尝试后,发现用'"'"'代替'就能正确的发送并执行, 见第10,11行,20,21行,24~27行。

下面以第10行为例分析其结构:

  1. 第一个单引号与外层的单引号结合,包裹它们中间的内容http -b GET $es, 保证里面的特殊字符($)不会被求值;

  2. 中间的"'"是一组,实现被tmux发送后,最终执行的命令行里仍有一个单引号 ($es后面的单引号);

  3. 最后的单引号,与后面的'"'"'中最左边的单引号组合, 包裹中间的内容/_cat/indices?v,如果没有这一组单引号, 里面的问好就会被求值,导致命令无法执行。

这一步实现了在第11、21、27行上按F3键可以正确执行。

整合进vim function

这一步将前面的测试结果放进vim函数中,实现在第10、20、24行上能够正确执行。

sendcmd.vim:

function! SendCurLineInTmux()
  let curline = getline(".")
  let escstr = substitute(curline, "'", "'\"'\"'", 'g')
  let cmd = "tmux send-keys -t right C-u '" . escstr . "' Enter"
  " echom cmd
  echom system(cmd)
endfunction
nnoremap <silent> <Space> :call SendCurLineInTmux()<CR>
vnoremap <silent> <Space> :call SendCurLineInTmux()<CR>

这里要注意的是 tmux send-keys -t right C-u 中的 C-u,是为了解决 IPython 的自动缩进问题,在 GHCi 中不存在这个问题,但加一个 Ctrl-u 也无妨。

IPython低版本中可以用 %autoindent 关闭自动缩进,但在6.2版本中这个方法不起作用了。 另外可以用 %cpaste 达到无缩进粘贴的目的,但要用 Ctrl-D 或者 -- 表示输入结束, 都不如在每一行首加 Ctrl-u 方便。

将上面的 sendcmd.vim 中的内容加入到 $MYVIMRC 中就可以了。

制作成插件

当功能基本定型后,可以把脚本变成插件,避免手工修改配置文件。 但对于很小的脚本来说,直接加入配置文件中也不麻烦。

最简单的vim插件,只要在一个目录(tmuxcmd)里创建一个plugin目录, 把.vim脚本放进去,然后把目录做成git库就行了:

mkdir -p tmuxcmd/plugin
mv test.vim tmuxcmd/plugin/tmuxcmd.vim
cd tmuxcmd
git init
git add ...; git commit -m "..."

在.vimrc里加入这个插件:增加一行代码:Plugin 'file:///home/leo/temp/tmuxcmd'

安装:vim +PluginInstall

安装过程实际是把代码库clone到~/.vim/bundle下, 修改~/.vim/bundle/tmuxcmd/plugin/tmuxcmd.vim文件在新的vim编辑器中不会生效。

本地插件需要保存一个本地目录,更简单的方法是发布到github上,再用vundle安装:

  1. 把代码库push到github上: leetschau/tmuxcmd

  2. 在.vimrc中,把原来的Plugin 'file:///home/leo/temp/tmuxcmd'换成 Plugin 'leetschau/tmuxcmd'



Published

Jul 2, 2016

Last Updated

Jun 10, 2020

Category

Tech

Tags

  • neoterm 1
  • plugin 16
  • tmux 10
  • vim 92

Contact

  • Powered by Pelican. Theme: Elegant by Talha Mansoor