使用脚本编写 Vim 编辑器,第 3 部分: 内置列表

探索 Vimscript 对列表和数组的支持

Vimscript 为操作数据集提供出色的支持,该特性是编程的核心之一。在 本系列 的第三篇文章中,了解如何使用 Vimscript 的内置列表来简化日常操作,比如重新格式化列表、过滤文件名的序列和对行号集进行排序。您还将学习一些展示列表的威力的例子,它们扩展并改进了 Vim 的两个常见用途:创建用户定义函数来对齐赋值操作符;改进内置文本补全机制。

Damian Conway, Dr., CEO 和首席培训师, Thoughtstream

作者照片 - damian conwayDamian Conway 是澳大利亚 Monash 大学计算机科学系的兼职副教授,并且是 Thoughtstream 的 CEO,这是一家国际性的 IT 培训公司。他是一位爱好 vi 的用户,拥有超过 25 年的经验。从目前看来,他似乎难以摆脱对 vi 的痴迷。



2010 年 3 月 01 日

所有编程的核心都是创建和操作数据结构。到目前为止,我们在 本系列 中仅讨论了 Vimscript 的标量数据类型(字符串、数字和布尔值)和用于储存它们的标量变量。只有 Vim 脚本能够同时操作所有相关的数据集时,Vim 编程的威力才能得到体现:重新格式化文本行、访问配置数据的多维表、过滤文件名的序列和对行号集进行排序。

在本文中,我将探索 Vimscript 对列表和数组的出色支持,以及该语言的许多内置函数,这些函数让列表的使用更容易、高效和可维护。

Vimscript 中的列表

在 Vimscript 中,列表是标量值的序列:字符串、数字、引用或它们之间的混合。

Vimscript 列表这样命名是有其理由的。在大多数语言中,“列表” 是一个值(而不是容器),一个由简单值组成的不可变序列。相反,Vimscript 中的列表的序列是可变的,并且在很多情况下类似于匿名数组数据结构。储存列表的 Vimscript 变量大多数情况下是一个数组。

您可以通过将一个以逗号分隔的标量值序列放在一对方括号中来创建列表。列表元素从 0 开始索引,并且通过常见的符号来访问和修改:使用其中包含索引的一对方括号:

清单 1. 创建列表
let data = [1,2,3,4,5,6,"seven"]
echo data[0]                 |" echoes: 1
let data[1] = 42             |" [1,42,3,4,5,6,"seven"]
let data[2] += 99            |" [1,42,102,4,5,6,"seven"]
let data[6] .= ' samurai'    |" [1,42,102,4,5,6,"seven samurai"]

您还可以使用小于 0 的索引,这些索引从列表的末尾往前算。因此,前面例子的最后一个语句可以这样写:

let data[-1] .=  ' samurai'

像在许多其他动态语言中一样,Vimscript 列表不需要显式的内存管理:它们自动伸缩以适应需要储存的元素,并且在应用程序不需要这些元素时自动进行垃圾回收。

嵌套列表

除了储存字符串或数字之外,列表还能储存其他列表。像在 C、C++ 或 Perl 中一样,如果一个列表包含其他列表,它就类似于多维数组。例如:

清单 2. 创建一个嵌套列表
let pow = [
\   [ 1, 0, 0, 0  ],
\   [ 1, 1, 1, 1  ],
\   [ 1, 2, 4, 8  ],
\   [ 1, 3, 9, 27 ],
\]
" and later...
echo pow[x][y]

在这里,单元格索引操作 (pow[x]) 以 pow 的形式返回列表的元素之一。该元素本身也是一个列表,因此第二个索引操作 ([y]) 返回嵌套列表的元素之一。

列表赋值和列表别名

当您将一个变量赋值给任何列表时,您就为该列表分配了一个指针或引用。因此,从一个列表变量分配到另一个列表变量将导致它们同时指向或引用相同的底层列表。这通常导致我们不希望发生的同时作用现象:

清单 3. 小心赋值
let old_suffixes = ['.c', '.h', '.py']
let new_suffixes = old_suffixes
let new_suffixes[2] = '.js'
echo old_suffixes      |" echoes: ['.c', '.h', '.js']
echo new_suffixes      |" echoes: ['.c', '.h', '.js']

为了避免别名效应,您需要调用内置的 copy() 函数来复制列表,然后改为对副本进行赋值:

清单 4. 复制列表
let old_suffixes = ['.c', '.h', '.py']
let new_suffixes = copy(old_suffixes)
let new_suffixes[2] = '.js'
echo old_suffixes      |" echoes: ['.c', '.h', '.py']
echo new_suffixes      |" echoes: ['.c', '.h', '.js']

不过要注意,copy() 仅复制列表的顶级。如果任何这些值本身是嵌套的列表,它就是指向其他独立的外部列表的指针/引用。对于这种情况,copy() 将复制该指针/引用,并且嵌套列表仍然被原始值和复制值所共享,如下所示:

清单 5. 浅复制
let pedantic_pow = copy(pow)
let pedantic_pow[0][0] = 'indeterminate'
" also changes pow[0][0] due to shared nested list

如果这不是您想要的(通常发生这种情况),您可以使用内置的 deepcopy() 函数,它会一直复制任何嵌套的数据结构:

清单 6. 深复制
let pedantic_pow = deepcopy(pow)
let pedantic_pow[0][0] = 'indeterminate'
" pow[0][0] now unaffected; no nested list is shared

基础的列表选项

Vim 的大部分列表操作都是通过内置函数来提供的。这些函数通常接受一个列表并返回它的某些属性:

清单 7. 查找大小、范围和索引
" Size of list...
let list_length   = len(a_list)
let list_is_empty = empty(a_list)    
 " same as: len(a_list) == 0" Numeric minima and maxima...
let greatest_elem = max(list_of_numbers)
let least_elem    = min(list_of_numbers)

" Index of first occurrence of value or pattern in list...
let value_found_at = index(list, value)      " uses == comparison
let pat_matched_at = match(list, pattern)    " uses =~ comparison

可以使用 range() 函数来生成一个整数列表。如果使用单整数参数调用该函数,它将生成一个范围为 0 至该参数之间的列表。如果使用两个参数调用该函数,它将生成一个范围为两个参数之间的系列列表。如果使用三个参数,还会生成一个系列列表,但是将以第三个参数作为增量递增每个元素:

清单 8. 使用 range() 函数生成列表
let sequence_of_ints = range(max)              " 0...max-1
let sequence_of_ints = range(min, max)         " min...max
let sequence_of_ints = range(min, max, step)   " min, min+step,...max

您还可以通过将字符串拆分成 “单词” 序列来生成列表:

清单 9. 通过拆分文本来生成列表
let words = split(str)                         " split on whitespace
let words = split(str, delimiter_pat)          " split where pattern matches

您还可以执行反向操作,将各个元素合并成原来的列表:

清单 10. 合并列表的元素
let str = join(list)                           " use a single space char to join
let str = join(list, delimiter)                " use delimiter string to join

其他与列表相关的方法

您可以在任何 Vim 会话中输入 :help function-list 来探索许多其他与列表相关的函数,然后向下滚动到 “列表操作”。大部分这些函数实际上是过程,因为它们现场修改它们的列表参数。

例如,要将一个额外元素插入到列表中,您可以使用 insert()add()

清单 11. 向列表添加值
call insert(list, newval)          " insert new value at start of list
call insert(list, newval, idx)     " insert new value before index idx
call    add(list, newval)          " append new value to end of list

您可以使用 extend() 插入值列表:

清单 12. 将一组值添加到列表
call extend(list, newvals)         " append new values to end of list
call extend(list, newvals, idx)    " insert new values before index idx

或者从列表删除指定的元素:

清单 13. 删除元素
call remove(list, idx)             " remove element at index idx
call remove(list, from, to)        " remove elements in range of indices

或对列表进行排序

清单 14. 对列表进行排序
call sort(list)                    " re-order the elements of list alphabetically
                                       
call reverse(list)                 " reverse order of elements in list

使用列表过程的常见错误

注意,所有与列表相关的过程也返回它们刚才修改的列表,因此您可能这样编写代码:

let sorted_list = reverse(sort(unsorted_list))

但是这样做通常是一个严重的错误,因为即使它们返回的值以这种方式使用,与列表相关的函数仍然修改它们的原始参数。因此,在前面的例子中,unsorted_list 中的列表还可能被进行排序或反转顺序。另外,unsorted_listsorted_list 的别名可能使用相同的排序和反转列表(见 “列表赋值和别名”)。

这对大多数程序员而言是很不直观的,他们通常希望 sortreverse 等函数返回原始数据的修改副本,而不改变原始数据本身。

Vimscript 列表并不是这样工作的,因此您需要培养良好的编程习惯,以避免这些槽糕的意外。这些习惯之一是永远只以纯函数的方式调用 sort()reverse() 等函数,并且要经常传递需要修改的数据的副本。您可以使用内置的 copy() 函数来实现该目的:

let sorted_list = reverse(sort(copy(unsorted_list)))

过滤和转换列表

两个非常有用的过程列表函数是 filter()map()filter() 函数接受一个列表作为参数并删除未能满足指定条件的元素:

let filtered_list = filter(copy(list), criterion_as_str)

调用 filter() 将作为第二个参数传递的字符串转换成一段代码,然后将该代码应用到作为第一个参数传递的列表的每个元素。换句话说,它将对第二个参数反复执行 eval() 函数。对于每次计算,它都通过特殊的变量 v:val 将第一个参数的下一个元素传递给代码。如果已计算的代码的结果为 0(即 false ),那么将从列表删除对应的元素。

例如,要从列表中删除所有负数,请输入:

let positive_only = filter(copy(list_of_numbers), 'v:val >= 0')

要从列表删除任何包含 /.*nix/ 样式的名称,请输入:

let non_starnix = filter(copy(list_of_systems), 'v:val !~ ".*nix"')

map() 函数

map() 函数类似于 filter(),与后者不同的是它不是删除部分元素,而是使用用户指定的原始值转换替换每个元素。它的语法为:

let transformed_list = map(copy(list), transformation_as_str)

filter() 一样,map() 计算作为第二个参数传递的字符串,并且通过 v:val 传递每个列表元素。不过,与 filter() 不同的是,map() 通常保留列表的每个元素,使用对每个值应用代码得到的结果替换这些值。

例如,要让列表中的每个数字增加 10,请输入:

let increased_numbers = map(copy(list_of_numbers), 'v:val + 10')

或者大写列表中的每个单词:

let LIST_OF_WORDS = map(copy(list_of_words), 'toupper(v:val)')

再次提醒一下,filter()map() 当场修改它们的第一个参数。使用这两个函数经常发生的错误是这样编写代码:

let squared_values = map(values, 'v:val * v:val')

正确的代码应该是这样:

let squared_values = map(copy(values), 'v:val * v:val')

列表合并

您可以使用 ++= 运算符合并列表,如下所示:

清单 15. 合并列表
let activities = ['sleep', 'eat'] + ['game', 'drink']

let activities += ['code']

记住,运算符两边必须都是列表。不要将 += 误认为是 “append”;您不能使用它来将单个值直接添加到列表的末尾:

清单 16. 合并需要两个列表
let activities += 'code'    
" Error: Wrong variable type for +=

子列表

您可以通过在索引操作的方括号中指定一个以逗号分隔的范围来提取列表的一部分。范围必须是常数、带数值的变量或任何数字表达式:

清单 17. 提取列表的一部分
let week = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
let weekdays = week[1:5]
let freedays = week[firstfree : lastfree-2]

如果您省略开始索引,子列表将自动从 0 开始;如果您省略结尾索引,子列表将以最后的元素作为结尾。例如,要将一个列表分成相等(近似相等)的两部分,请输入:

清单 18. 将一个列表分成两个子列表
let middle = len(data)/2
let first_half  = data[: middle-1]    " same as: data[0 : middle-1]
let second_half = data[middle :]      " same as: data[middle : len(data)-1]

示例 1:回顾自动对齐

通过例子能够很好地展示列表的威力和功能。让我们从改进一个现有的工具开始。

使用脚本编写 Vim 编辑器,第 2 部分:用户定义函数 探索了一个名为 AlignAssignments() 的用户定义函数,它以整齐的列对齐操作符。清单 19 再现了该函数。

清单 19. 原来的 AlignAssignments() 函数
function AlignAssignments ()
    " Patterns needed to locate assignment operators...
    let ASSIGN_OP   = '[-+*/%|&]\?=\@<!=[=~]\@!'
    let ASSIGN_LINE = '^\(.\{-}\)\s*\(' . ASSIGN_OP . '\)'

    " Locate block of code to be considered (same indentation, no blanks)
    let indent_pat = '^' . matchstr(getline('.'), '^\s*') . '\S'
    let firstline  = search('^\%('. indent_pat . '\)\@!','bnW') + 1
    let lastline   = search('^\%('. indent_pat . '\)\@!', 'nW') - 1
    if lastline < 0
        let lastline = line('$')
    endif

    " Find the column at which the operators should be aligned...
    let max_align_col = 0
    let max_op_width  = 0
    for linetext in getline(firstline, lastline)
        " Does this line have an assignment in it?
        let left_width = match(linetext, '\s*' . ASSIGN_OP)

        " If so, track the maximal assignment column and operator width...
        if left_width >= 0
            let max_align_col = max([max_align_col, left_width])

            let op_width      = strlen(matchstr(linetext, ASSIGN_OP))
            let max_op_width  = max([max_op_width, op_width+1])
        endif
    endfor

    " Code needed to reformat lines so as to align operators...
    let FORMATTER = '\=printf("%-*s%*s", max_align_col, submatch(1),
    \                                    max_op_width,  submatch(2))'

    " Reformat lines with operators aligned in the appropriate column...
    for linenum in range(firstline, lastline)
        let oldline = getline(linenum)
        let newline = substitute(oldline, ASSIGN_LINE, FORMATTER, "")
        call setline(linenum, newline)
    endfor
endfunction

这个函数的一个不足之处是它必须两次处理一个行:第一次(在第一个 for 循环)在段落的现有结构上收集信息,第二次(在最后一个 for 循环)调整每个行以适合新的结构。

这种重复的工作明显有待改善。将行储存在内部数据结构中然后直接重用它们会更好。了解您如何处理该列表,事实上您可以更加高效和干净地重写 AlignAssignments()。清单 20 显示了修改后的函数,它利用了几个列表数据结构和前面描述的各种列表操作函数的优势。

清单 20. 更新后的 AlignAssignments() 函数
function! AlignAssignments ()
    " Patterns needed to locate assignment operators...
    let ASSIGN_OP   = '[-+*/%|&]\?=\@<!=[=~]\@!'
    let ASSIGN_LINE = '^\(.\{-}\)\s*\(' . ASSIGN_OP . '\)\(.*\)$'

    " Locate block of code to be considered (same indentation, no blanks)
    let indent_pat = '^' . matchstr(getline('.'), '^\s*') . '\S'
    let firstline  = search('^\%('. indent_pat . '\)\@!','bnW') + 1
    let lastline   = search('^\%('. indent_pat . '\)\@!', 'nW') - 1
    if lastline < 0
        let lastline = line('$')
    endif

    " Decompose lines at assignment operators...
    let lines = []
    for linetext in getline(firstline, lastline)
        let fields = matchlist(linetext, ASSIGN_LINE)
        call add(lines, fields[1:3])
    endfor

    " Determine maximal lengths of lvalue and operator...
    let op_lines = filter(copy(lines),'!empty(v:val)')
    let max_lval = max( map(copy(op_lines), 'strlen(v:val[0])') ) + 1
    let max_op   = max( map(copy(op_lines), 'strlen(v:val[1])'  ) )

    " Recompose lines with operators at the maximum length...
    let linenum = firstline
    for line in lines
        if !empty(line)
            let newline
            \    = printf("%-*s%*s%s", max_lval, line[0], max_op, line[1], line[2])
            call setline(linenum, newline)
        endif
        let linenum += 1
    endfor
endfunction

注意,新函数中的前两个代码块与原来函数几乎相同。和原来一样,它们根据文本的当前缩进来定位需要对其的行的范围。

从第三个代码块开始发生变化,它使用内置的 getline() 函数的双参数形式来返回需要重新对齐的行的列表。

然后,for 循环遍历每个行,使用内置的 matchlist() 函数根据 ASSIGN_LINE 中的正则表达式匹配它:

let fields = matchlist(linetext, ASSIGN_LINE)

调用 matchlist() 返回正则表达式捕捉到的所有字段的列表(即任何与 \(...\) 分隔符中的模式部分匹配的元素)。在这个例子中,如果匹配成功,生成的字段是一些片段,它们分隔任何对齐线的 lvalue、操作符和 rvalue

尤其是,成功调用 matchlist() 将返回一个包含以下元素的列表:

  • 完整的行(因为 matchlist() 通常返回所有匹配作为首个元素)
  • 赋值操作符左边的所有内容
  • 赋值操作符本身
  • 赋值操作符右边的所有内容

对于这种情况,调用 add() 将最后 3 个字段的子列表添加到行列表。如果匹配失败(即该行不包含赋值项),那么 matchlist() 将返回一个空列表,从而使 add() 附加的子列表(下面的 fields[1:3])也为空。这用于表明不需要重新格式化的行:

call add(lines, fields[1:3])

第四个代码块部署 filter()map() 函数来分析包含赋值项的每个行的结构。它首先使用一个 filter() 来过滤行列表,仅保留被前一个代码块成功分解成多个部分的行:

let op_lines = filter(copy(lines), '!empty(v:val)')

接下来的函数确定每个赋值项的 lvalue 的长度,这通过将 strlen() 函数映射到已过滤的函数的副本上来实现:

map(copy(op_lines), 'strlen(v:val[0])')

然后,生成的 lvalue 长度列表被传递给内置的 max() 函数来确定任何赋值项中的最长 lvalue。最大长度决定所有赋值操作符都要根据其对齐的列(即超过最宽的 lvalue 的列):

let max_lval = max( map(copy(op_lines),'strlen(v:val[0])') ) + 1

同样,第四个代码块的最后一行决定容纳找到的各种赋值操作符所需的最大列数,它首先进行映射,然后最大化每个字符串的长度:

let max_op = max( map(copy(op_lines),'strlen(v:val[1])'  ) )

最后的代码块同时遍历原来的缓存行号 (linenum) 和行列表中的每个行,以重新格式化赋值行:

let linenum = firstline

for line in lines

该循环的每次遍历检查是否需要重新格式化某个行(即它是否围绕赋值操作成功分解)。如果这样,该函数将修改该行,使用 printf() 来重新格式化行的元素:

if !empty(line)
    let newline = printf("%-*s%*s%s", max_lval, line[0], max_op, line[1], line[2])

然后通过调用 setline() 将新的行写回到编辑器的缓存中,并且更新行跟踪以准备下一次遍历:

    call setline(linenum, newline)
endif
let linenum += 1

当所有行处理完成之后,将完全更新缓存,并且所有相关的赋值操作符都对齐到一个合适的列。因为它能够利用 Vimscript 对列表和列表操作的出色支持,修改后的 AlignAssignments() 比原来缩短了大约 15%。不过,更重要的是该函数的缓存访问量只有原来的三分之一,并且代码更加简洁,更加易于维护。


示例 2:改进 Vim 的补全功能

Vim 有一个先进的内置文本补全机制,您可以在任何 Vim 会话中输入 :help ins-completion 了解它。

最常用的补全模式之一是 关键字补全。您可以在插入文本时通过按下 CTRL-N 使用它。按下该组合键之后,将搜索各个不同的位置(由 “complete” 选项指定),查找与光标之前的字符开头的任何单词。默认情况下查找当前编辑的缓存、同一个会话中编辑的其他缓存、装载的任何标记文件,以及从文本包含的任何文件(使用 include 选项包含文本的文件)。

例如,如果在缓存中有两个段落,那么 —— 在插入模式下 —— 您将输入:

My use of Vim is increasingly so<CTRL-N>

Vim 文本并确定以 "so..." 开头的唯一单词为 sophisticated,然后立即补全该单词:

My use of Vim is increasingly sophisticated_

另一方面,如果您输入:

My repertoire of editing skills is bu<CTRL-N>

Vim 将检查到 3 个可能的补全:builtbufferbuffers。默认情况下,将显示一个候选菜单:

清单 21. 包含候选项的文本补全
My repertoire of editing skills is bu_
                                   built
                                   buffer
                                   buffers

然后,您可以分别使用 CTRL-NCTRL-P(或上下箭头)来在菜单的候选项上移动并选择您想要的单词。

要取消自动补全,输入 CTRL-E;要接受并插入当前选择的候选项,输入 CTRL-Y。输入任何键(通常是空格键或回车键)也将接受并插入当前选择的单词。

设计更加智能的补全

毫无疑问,Vim 的内置补全机制是极其有用的,但它还不是很智能。默认情况下,它仅匹配 “关键字” 字符序列(数字、字母和下划线),除了与光标左边进行匹配之外,它对上下文没有深刻的理解。

此外,补全机制还不是非常符合人体力学原理。CTRL-N 不是最容易输入的组合键,也不是程序员经常按的键。大部分命令行用户更加习惯使用 TABESC 作为补全键。

令人高兴的是,我们能够轻松修改这些不便之处。让我们在插入模式下重新定义 TAB 键,从而使它能够在光标的任意一边识别文本中的模式,并为上下文选择适合的补全。我们还进行了这样的设置,如果新的机制不能识别当前的插入上下文,它将切换到 Vim 的内置 CTRL-N 补全机制。在设置完成之后,我们还要确保能够使用 TAB 键来输入制表符。

指定更智能的补全

要构建这个更加智能的补全机制,我们需要储存为一个补全请求储存一系列 “上下文响应”。或者是列表的列表,假定每个上下文响应本身有 4 个元素组成。清单 22 显示如何设置该数据结构。

清单 22. 在 Vimscript 中设置查找表
" Table of completion specifications (a list of lists)...
let s:completions = []
" Function to add user-defined completions...
function! AddCompletion (left, right, completion, restore)
    call insert(s:completions, [a:left, a:right, a:completion, a:restore])
endfunction
let s:NONE = ""
" Table of completions...
"                    Left   Right    Complete with...       Restore
"                    =====  =======  ====================   =======
call AddCompletion(  '{',   s:NONE,  "}",                      1    )
call AddCompletion(  '{',   '}',     "\<CR>\<C-D>\<ESC>O",     0    )
call AddCompletion(  '\[',  s:NONE,  "]",                      1    )
call AddCompletion(  '\[',  '\]',    "\<CR>\<ESC>O\<TAB>",     0    )
call AddCompletion(  '(',   s:NONE,  ")",                      1    )
call AddCompletion(  '(',   ')',     "\<CR>\<ESC>O\<TAB>",     0    )
call AddCompletion(  '<',   s:NONE,  ">",                      1    )
call AddCompletion(  '<',   '>',     "\<CR>\<ESC>O\<TAB>",     0    )
call AddCompletion(  '"',   s:NONE,  '"',                      1    )
call AddCompletion(  '"',   '"',     "\\n",                    1    )
call AddCompletion(  "'",   s:NONE,  "'",                      1    )
call AddCompletion(  "'",   "'",     s:NONE,                   0    )

我们创建的列表的列表将用作上下文响应规范表,并且储存在列表变量 s:completions 中。列表中的每个条目本身就是一个包含 4 个值的列表:

  • 一个指定正则表达式来匹配光标左边的内容的字符串
  • 一个指定正则表达式来匹配光标右边的内容的字符串
  • 一个在检测到两个上下文之后插入的字符串
  • 一个在插入补全文本之后表明是否自动将光标恢复到补全前位置的标记

为了填充该表,我们创建了一个小函数:AddCompletion()。该函数接受 4 个参数:上下文的左边和右边、用于替换的文本和 “restore cursor” 标记。这些参数被放到一个列表中:

[a:left, a:right, a:completion, a:restore]

然后,使用 insert() 函数将该列表附加到 s:completions 变量的后面:

call insert(s:completions, [a:left, a:right, a:completion, a:restore])

因此重复调用 AddCompletion() 将创建一个由列表组成的列表,每个列表都指定一种补全。清单 22 中的代码用于实现该目的。

首次调用 AddCompletion()

"                    Left   Right    Complete with...       Restore
"                    =====  =======  ====================   =======
call AddCompletion( '{',    s:NONE,  '}',                   1      )

指定当新的机制在光标的左边遇到花括号时,它应该插入一个表示结束的花括号,然后将光标恢复到补全之前的位置。即在补全时:

while (1) {_

(where the _ represents the cursor), the mechanism will now produce:

while (1) {_}

让光标留在一对花括号的中间,从而为输入提供方便。

第二次调用 AddCompletion()

"                    Left   Right    Complete with...       Restore
"                    =====  =======  ====================   =======
call AddCompletion(  '{',   '}',     "\<CR>\<C-D>\<ESC>O",  0      )

然后仍然让补全机制保持智能。它指定该机制在光标的左边和右边分别遇到左花括号和右花括号时,它应该插入一个新行,减少缩进右花括号(通过 CTRL-D),然后退出插入模式(ESC)并在右花括号上面打开一个新行(O)。

假设启用了 “smartindent” 选项,该序列的效果是在下面的上下文中按下 TAB

while (1) {_}

该机制将生成:

while (1) {
    _
}

换句话说,由于向补全表添加了两个项,在左花括号之后的 TAB 补全在相同的行上关闭,然后立即执行第二个 TAB 补全,将区块 “扩展” 到几个行(使用正确的缩进)。

剩余的 AddCompletion() 调用为 3 个其他括号(方括号,圆括号和尖括号)重复该排列,并且为单引号和双引号提供特别的补全语义。在双引号之后补全将添加匹配的双引号,而在两个双引号之间补全将添加一个 \n(新行)元字符。在单引号之后补全将添加匹配的单引号,而第二次补全尝试将不添加任何东西。

实现更智能的补全

一旦设立了补全规范之后,剩下的任务就是实现一个功能来从表选择适当的补全机制,然后将该函数绑定到 TAB 键。清单 23 显示了该代码。

清单 23. 更加智能的补全功能
" Implement smart completion magic...
function! SmartComplete ()
    " Remember where we parked...
    let cursorpos = getpos('.')
    let cursorcol = cursorpos[2]
    let curr_line = getline('.')

    " Special subpattern to match only at cursor position...
    let curr_pos_pat = '\%' . cursorcol . 'c'

    " Tab as usual at the left margin...
    if curr_line =~ '^\s*' . curr_pos_pat
        return "\<TAB>"
    endif

    " How to restore the cursor position...
    let cursor_back = "\<C-O>:call setpos('.'," . string(cursorpos) . ")\<CR>"

    " If a matching smart completion has been specified, use that...
    for [left, right, completion, restore] in s:completions
        let pattern = left . curr_pos_pat . right
        if curr_line =~ pattern
            " Code around bug in setpos() when used at EOL...
            if cursorcol == strlen(curr_line)+1 && strlen(completion)==1 
                let cursor_back = "\<LEFT>"
            endif

            " Return the completion...
            return completion . (restore ? cursor_back : "")
        endif
    endfor

    " If no contextual match and after an identifier, do keyword completion...
    if curr_line =~ '\k' . curr_pos_pat
        return "\<C-N>"

    " Otherwise, just be a <TAB>...
    else
        return "\<TAB>"
    endif
endfunction

" Remap <TAB> for smart completion on various characters...
inoremap <silent> <TAB>   <C-R>=SmartComplete()<CR>

SmartComplete() 函数首先使用内置的 getpos() 函数和一个 '.' 参数(即 “获取光标的位置”)来定位光标。该调用返回一个包含 4 个元素的列表:缓存号(通常为 0)、行号和列号(都从 1 开始索引),以及一个特殊的 “虚拟偏移量”(通常也为 0,但在这里不相关)。我们主要对中间的两个元素感兴趣,因为它们表明光标的位置。尤其是,SmartComplete() 需要列号,它通过在 getpos() 返回的列表中建立索引来提取,如下所示:

let cursorcol = cursorpos[2]

该函数还需要知道当前行上的文本,可以使用 getline() 来获取存储在 curr_line 中的当前行。

SmartComplete() 将把 s:completions 表中的每个条目转换成根据当前行进行匹配的模式。为了正确地在光标周围匹配左边和右边上下文,要确保该模式仅在光标所在的列进行匹配。Vim 有一个专门的子模式来实现该功能:\%Nc(其中 N 是所需的列号)。因此,该函数通过插入前面找到的光标的列位置来创建子模式:

let curr_pos_pat = '\%' . cursorcol . 'c'

因为我们最终将该函数绑定到 TAB 键,所以我们希望该函数仍然在可能的时候插入一个 TAB,尤其是在行的开头。因此 SmartComplete() 首先检查光标的左边是否存在空格,如果存在将返回一个简单的制表符:

if curr_line =~ '^\s*' . curr_pos_pat
    return "\<TAB>"
endif

如果光标不在行的开头,那么 SmartComplete() 需要检查补全表中的所有条目,以确定是否存在适用的条目。部分这些条目将指定光标必须在补全之后返回到初始位置,这将要求从插入模式执行一个定制命令。该命令仅调用内置的 setpos() 函数,将原始的值信息从先前的调用传导到 getpos()。要从插入模式执行该函数需要一个 CTRL-O 转义(在任何 Vim 会话中查看 :help i_CTRL-O)。因此 SmartComplete() 将必要的 CTRL-O 命令预构建为一个字符串并存储在 cursor_back 中:

let cursor_back = "\<C-O>:call setpos('.'," . string(cursorpos) . ")\<CR>"

更加高级的 for 循环

为了遍历补全列表,该函数使用一个特殊版本的 for 语句。Vimscript 中的标准 for 循环遍历一维的列表,一次遍历一个元素:

清单 24. 标准的 for 循环
for name in list
    echo name
endfor

不过,如果列表是二维的(即每个元素本身是一个列表),那么您通常想要在遍历时 “解开” 每个嵌套列表的内容。为此,您可以这样做:

清单 25. 遍历嵌套列表
for nested_list in list_of_lists
    let name   = nested_list[0] 
    let rank   = nested_list[1] 
    let serial = nested_list[2] 
    
    echo rank . ' ' . name . '(' . serial . ')'
endfor

但是 Vimscript 提供更加简洁的办法:

清单 26. 遍历嵌套列表的简洁方法
for [name, rank, serial] in list_of_lists
    echo rank . ' ' . name . '(' . serial . ')'
endfor

循环在每次遍历时从 list_of_lists 获取下一个嵌套列表,并将该嵌套列表的第一个元素赋值给 name,将第二个嵌套元素赋值给 rank,将第三个嵌套元素赋值给 serial

使用这个特殊版本的 for 循环让 SmartComplete() 遍历补全表更加容易,并且为每个补全的各个组件提供一个逻辑名:

for [left, right, completion, restore] in s:completions

识别补全上下文

在该循环内部,SmartComplete() 通过将左右上下文模式放到与光标匹配的子模式周围构造一个正则表达式:

let pattern = left . curr_pos_pat . right

如果当前的行匹配生成的正则表达式,那么该函数就找到正确的补全(它的文本已经在补全中)并且能够立即返回它。当然,它还需要附加先前构建的光标复位命令,如果选择的补全要求这样做的话(即 restore 为 true)。

不幸的是,基于 setpos() 的光标复位命令有一个问题。在 Vim 版本 7.2 或更早的版本中,setpos() 有一个怪异的行为:如果光标原先在行尾并且补全文本只有一个字符,它就不会在插入模式下正确地复位光标。对于这种特殊情况,必须将复位命令更改为左箭头,它将光标移动回到新插入的字符之前。

因此在返回选择的补全之前,需要添加以下代码:

清单 27. 在行尾插入单字符之后恢复光标的位置
if cursorcol == strlen(curr_line)+1 && strlen(completion)==1 
    let cursor_back = "\<LEFT>"
endif

还需要做的就是返回选择的补全,在请求光标复位时添加 cursor_back 命令:

return completion . (restore ? cursor_back : "")

如果这些来自补全表的条目与当前的上下文不匹配,SmartComplete() 将退出 for 循环,然后尝试剩余的其他两个选择。如果在光标前面的字符是 “关键字”,它将通过返回一个 CTRL-N 调用正常的关键字补全:

清单 28. 返回到 CTRL-N 行为
" If no contextual match and after an identifier, do keyword completion...
if curr_line =~ '\k' . curr_pos_pat
    return "\<C-N>"

否则其他补全将不可行,因此它通过返回一个制表符来充当正常的 TAB 键:

清单 29. 返回到正常的 TAB 键行为
" Otherwise, just be a <TAB>...
else
    return "\<TAB>"
endif

部署新机制

现在我们需要让 TAB 键调用 SmartComplete(),以了解它将插入什么内容。这可以通过一个 inoremap 来完成:

inoremap <silent> <TAB>   <C-R>=SmartComplete()<CR>

键映射将任何插入模式 TAB 转换成一个 CTRL-R=,调用 SmartComplete() 并插入它返回的补全字符串(参见 :help i_CTRL-R使用脚本编写 Vim 编辑器,第 1 部分:变量、值和表达式 了解该机制的细节)。

在这里使用 imapinoremap 形式,因为 SmartComplete() 返回的一些补全字符串还包含 TAB 字符。如果使用常规的 imap,插入返回的 TAB 将立即导致重新调用相同的键映射,再次调用 SmartComplete(),这将返回另一个 TAB,等等。

使用 inoremap 得到的 TAB 键能够:

  • 识别特殊的用户定义插入上下文并正确地补全它们
  • 在标识符之后返回到常规的 CTRL-N 补全
  • 在其他地方仍然用作 TAB

此外,将清单 22 和清单 23 放到 .vimrc 文件之后,您就可以通过额外调用 AddCompletion() 来扩展补全表,从而方便地添加新的上下文补全。例如,使用下面的代码能够更轻松地开始新的 Vimscript 函数:

call AddCompletion( 'function!\?',  "",  "\<CR>endfunction", 1 )

这使得在函数关键字之后立即使用制表符会将对应的 endfunction 关键字附加到下一行。

或者,您可以巧妙地自动补全 C/C++ 注释(假设设置了 cindent 选项):

call AddCompletion( '/\*', "",    '*/',                        1 )
call AddCompletion( '/\*', '\*/', "\<CR>* \<CR>\<ESC>\<UP>A",  0 )

这使得:

/*_<TAB>

将一个结束注释分隔符添加到光标之后:

/*_*/

并且在该位置的第二个 TAB 插入一个美观的多行注释,并将光标移动到注释中间:

/*
 * _
 */

结束语

储存和操作数据列表的能力使 Vimscript 能够轻松处理更多任务,但是列表通常不是收集和储存信息的理想解决办法。例如,清单 20 中修改后的 AlignAssignments() 包含一个如下所示的 printf() 调用:

printf("%-*s%*s%s", max_lval, line[0], max_op, line[1], line[2])

对代码行的各个元素使用 line[0]line[1]line[2] 显然不利于阅读,这样不仅在最初实现时容易出错,而且让日后的维护变得困难。

这是一种常见的情形:相关的数据需要收集在一起,但它们没有内在的或有意义的顺序。对于这种情况,使用逻辑名对每条数据进行标识要比使用数字索引好。当然,我们通常可以创建一组变量来 “命名” 各个数字常量:

let LVAL = 0
let OP   = 1
let RVAL = 2

" and later...

printf("%-*s%*s%s", max_lval, line[LVAL], max_op, line[OP], line[RVAL])

但这是一种笨拙的解决办法,如果在行列表中改变了各个元素的顺序,将导致难以发现的错误,并且没有适当地更新变量。

由于命名数据集合在编程中是一种常见的需求,所以大部分动态语言中都通过一个通用的结构来提供它们:关联数组,或散列表,或字典。当然,Vim 也包含字典。在本系列的下一篇文章中,我们将探索 Vimscript 如何实现这个非常有用的数据结构。

参考资料

学习

获得产品和技术

讨论

  • 加入 My developerWorks 社区。查看开发人员参与的博客、论坛、组和 wiki,并与其他 developerWorks 用户交流。

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Linux
ArticleID=470186
ArticleTitle=使用脚本编写 Vim 编辑器,第 3 部分: 内置列表
publish-date=03012010