高级折叠

在上一章节中,我们使用了 Vim 的 indent 折叠来给 Potion 文件添加一些快速但有污染的折叠。

打开 factorial.pn 文件,按下 zM 以确保所有的折叠都关闭了。现在文件应该看上去如下:

factorial = (n):
+--  5 lines: total = 1

10 times (i):
+--  4 lines: i string print

切换第一个折叠,然后会如下:

factorial = (n):
    total = 1
    n to 1 (i):
+---  2 lines: # Multiply the running total.
    total.

10 times (i):
+--  4 lines: i string print

这看上去很棒,但是我个人更喜欢把代码块的第一行和它的内容一起折叠。 在本章节中,我们会编写一些自定义的折叠代码,当我们完成后,这些折叠将会看上去像这样:

factorial = (n):
    total = 1
+---  3 lines: n to 1 (i):
    total.

+--  5 lines: 10 times (i):

这样更紧凑并且易于阅读(对于我来说)。如果你更喜欢 indent 的方式也是可以的, 但是无论如何也请完成本章,这样可以获得一些编写 Vim 折叠表达式的实践经验。

折叠原理

如果了解 Vim 是如何对折叠进行“思考”的,将会对编写自定义折叠代码起到帮助。 简单的说,有以下几点规则:

用一个简单的例子就能切身体会到。打开一个 Vim 窗口,把以下文本复制进去。

a
    b
    c
        d
        e
    f
g

用以下命令打开 indent 折叠:

:setlocal foldmethod=indent

随意试试看,观察它们的行为。

现在运行以下命令来看看第1行的折叠层级:

:echom foldlevel(1)

Vim 会显示 0。现在,让我们看看第2行:

:echom foldlevel(2)

Vim 会显示 1。再试试第3行:

:echom foldlevel(3)

Vim 再次显示了 1。这意味着第2和第3行是折叠层级1的一部分。

下面是每一行的折叠层级:

a           0
    b       1
    c       1
        d   2
        e   2
    f       1
g           0

重新阅读这一段的开头。打开、关闭文件中的每个折叠,并关注折叠层级,确保能理解为什么这样折叠。

一旦你确信理解了每一行的折叠层级是如何影响整个折叠结构的,那就继续下一段吧。

首先:制定一个计划

在全心投入编写代码之前,让我们先试着勾勒出一些大致的“规则”出来。

首先,有缩进的行应该被折叠在一起。 同时,也想把“前”一行折叠进去,所以,以下代码:

hello = (name):
    'Hello, ' print
    name print.

会被折叠成这样:

+--  3 lines: hello = (name):

空行应该和“后”一行是一样的层级,所以折叠之后的空行不会被包含进去。 这意味着这段代码:

hello = (name):
    'Hello, ' print
    name print.

hello('Steve')

会被折叠成这样:

+--  3 lines: hello = ():

hello('Steve')

不是这样:

+--  4 lines: hello = ():
hello('Steve')

这些规则完全是个人喜好,但现在,这些就我们将要实现的折叠方式。

准备开始

打开两个 Vim 分割窗口,开始实现我们的自定义折叠吧。 一个应该是 ftplugin/potion/folding.vim 文件,而另一个应该是样例文件 factorial.pn

在上一章节中,我们关闭并重新打开 Vim 来使 folding.vim 中的修改生效,但实际上有一个更简单的方法。

记住,任何 ftplugin/potion/ 中的文件都会在缓冲的 filetype 被设置为 potion 时运行。 这意味着你可以直接在包含 factorial.pn 的分割窗口中运行 :set ft=potion ,Vim 就会重载折叠代码!

这可比每次关闭并重新打开文件快多了。 你只要记得把 folding.vim 保存进硬盘就行了,否则没保存的修改就丢了。

表达式折叠

我们会使用 Vim 的 expr 折叠来实现无限灵活的折叠方式。

我们可以先把 folding.vim 中的 foldignore 删除,因为它只和 indent 折叠有关。 我们也要告诉 Vim 使用 expr 折叠,所以把 folding.vim 中的内容改成以下这样:

setlocal foldmethod=expr
setlocal foldexpr=GetPotionFold(v:lnum)

function! GetPotionFold(lnum)
    return '0'
endfunction

第一行只是告诉 Vim 使用 expr 折叠。

第二行定义了 Vim 用来获取某行折叠层级的表达式。 当 Vim 运行这个表达式时,会把 v:lnum 设置为某行的行号。 而表达式会以这个数字为参数调用自定义方法。

最后,我们定义了一个假的函数,为每行都返回了 '0'。 注意,这里返回的是字符串而不是整数。我们之后会简要地说说为什么。

保存 folding.vim,并在 factorial.pn 中运行 :set ft=potion 来重载折叠代码。 我们的函数为每行都返回了 '0',所以 Vim 不会折叠任何代码。

空行

让我们先来处理下空行的特殊情况。把 GetPotionFold 函数改为如下这样:

function! GetPotionFold(lnum)
    if getline(a:lnum) =~? '\v^\s*$'
        return '-1'
    endif

    return '0'
endfunction

我们添加了一个 if 语句来处理空行。它是如何起效的呢?

首先,我们用 getline(a:lnum) 获取当前行的内容,这是个字符串。

再把它和正则表达式 \v^\s*$ 比较。记住,\v 会开启“very magic”(“健全的”)模式。 这个正则表达式会匹配“行开始位置,任何空白字符,行结束位置”。

比较符用的是大小写不敏感匹配的操作符 =~?。 技术上来说,并不需要考虑大小写,因为我们只是匹配空白符,但我宁愿在比较字符串的时候更明确些。 如果你愿意,完全可以使用 =~ 来替代。

如果你需要复习正则表达式的使用,可以去重新阅读“基本正则表达式”和“grep 操作符”的章节。

如果当前行有些非空白的字符,就不匹配了,而我们还是像之前一样返回 '0'

如果当前行确实匹配上了(也就是说,如果是空的,或者只有空白符),我们会返回字符串 '-1'

之前,我说过某行的折叠层级只能是0或者正整数,那这是什么情况呢?

特殊折叠层级

自定义折叠表达式可以直接返回折叠层级,或者返回一些“特殊”的字符串, 它会告诉 Vim 如何折叠这行,而不是直接指定层级。

'-1' 就是这样一种特殊的字符串。它告诉 Vim,这行的层级是“未定义的”。 Vim 会把它解释为“这行的折叠层级等于上一行或下一行的层级,这取决于哪个更小”。

这和我们的计划并不完全一致,但也看得出来已经很接近了,而且能达到我们想要的效果。

Vim 能把这些未定义的行“串联”在一起,所以,如果有两个连续空行,后面跟了一行层级为1的, 那么会把最后一行未定义的设为1,然后旁边的,也就是第一行设为1.

在编写自定义折叠代码时,你经常会发现某些行可以毫无疑问的设为特殊层级。 然后,可以用 '-1' (以及一些其他的特殊层级,我们后面会讲到) 把恰当的层级“级联”到文件剩余的部分。

如果你为 factorial.pn 重载折叠代码,Vim 仍然不会把任何行折叠在一起。 这是因为所有行的折叠层级都是 0 或者 “未定义”。 层级 0 会“级联”到所有未定义的行,直到最后所有行的折叠层级都是 0

缩进层级辅助函数

要处理非空行,就需要知道它们的缩进层级,所以,我们要先创建一个小小的辅助函数来为我们计算。 在 GetPotionFold 上面添加以下函数:

function! IndentLevel(lnum)
    return indent(a:lnum) / &shiftwidth
endfunction

重载折叠代码。在 factorial.pn 缓冲中运行以下命令,测试下我们的函数:

:echom IndentLevel(1)

Vim 显示 0,因为第1行没有被缩进。现在试试第2行:

:echom IndentLevel(2)

这次,Vim 显示 1。第2行在开始处有4个空格,而 shiftwidth 设为了4,所以4除以4等于1.

IndentLevel 相当的直截了当。indent(a:lnum) 返回给定行数的行在开始处拥有的空格数量。 我们把它除以缓冲的 shiftwidth 值,就得到了缩进层级。

我们为什么用 &shiftwidth 而不是直接除以4? 假设有些人喜欢在 Potion 文件中使用2个空格的缩进,那么除以4会产生错误的结果。 使用 shiftwidth 选项就允许用任何数量的空格来表示一层缩进了。

另一个辅助函数

从现在开始就不太明确前进的方向了。 让我们先停下来想一想,我们需要什么样的信息才能计算出如何折叠非空行。

我们需要知道行本身的缩进层级。通过 IndentLevel 函数就能知道,所以我们已经准备好了。

我们也需要知道下一个非空行的缩进层级,因为我们要把“头部”行以及缩进的主体行都折叠起来。

让我们写一个辅助函数来获取指定行之后的下一个非空行的行号。 在 IndentLevel 上面添加以下函数:

function! NextNonBlankLine(lnum)
    let numlines = line('$')
    let current = a:lnum + 1

    while current <= numlines
        if getline(current) =~? '\v\S'
            return current
        endif

        let current += 1
    endwhile

    return -2
endfunction

这个函数有点长,但是非常简单。让我们一步一步来讲解。

首先,我们通过 line('$') 获取文件总的行数,并储存起来。 通过文档查看 line() 是如何工作的。

下一步,我们把变量 current 设为下一行的行号。

然后,我们开启一个循环,遍历文件中的每一行。

如果这行和正则表达式 \v\S 匹配,那就意味着“匹配上了一个空白的字符”, 那它肯定就是非空行,所以我们要返回它的行号。

如果这行不匹配,我们继续遍历下一行。

如果循环遍历完所有行都没有返回,那么在当前行之后就没有非空行! 如果是这样,我们就返回 -2 来表示。 -2 并不是一个有效的行号,所以这是种简便的方法来表示“抱歉,没有有效的结果”。

我们也可以返回 -1,因为这也不是一个有效的行号。 我甚至可以用 0,因为 Vim 中的行号是以 1 开始的! 那么我为什么用了 -2 这个看起来有些奇怪的选择?

我们用 -2 是因为我们正在编写折叠代码,而 '-1' (和 '0')是 Vim 中一种特殊的折叠层级。

当我阅读这个文件并看到 -1 时,我脑中会立马想到“未定义折叠层级”。0 也是这样。 我在这选择 -2 就是要明确地表明这不是一个折叠层级,而是一个“错误”。

如果这让你感到不舒服,可以把 -2 改为 -1 或者 0,这不会有问题。 这只是一种编程风格的偏好。

完成折叠函数

这注定会是一个非常长的章节,让我们把注意力完全集中到折叠函数上来。 把 GetPotionFold 修改成下面这样:

function! GetPotionFold(lnum)
    if getline(a:lnum) =~? '\v^\s*$'
        return '-1'
    endif

    let this_indent = IndentLevel(a:lnum)
    let next_indent = IndentLevel(NextNonBlankLine(a:lnum))

    if next_indent == this_indent
        return this_indent
    elseif next_indent < this_indent
        return this_indent
    elseif next_indent > this_indent
        return '>' . next_indent
    endif
endfunction

有很多新的代码!让我们逐句来看看它都是如何工作的。

空行

首先,我们要检查空行。这里没有任何修改。

如果越过了检查,那我们就能知道当前是一个非空行。

找到缩进层级

然后,用之前2个辅助函数来获取当前行以及下一个非空行的缩进层级。

你也许会想知道,如果 NextNonBlankLine 返回 -2 的错误情况会怎么样。 如果是这样,indent(-2) 会被运行。而以一个不存在的行号来运行 indent() 会直接返回 -1。 自己用 :echom indent(-2) 试试看。

-1 除以任何大于 1shiftwidth 都会返回 0。 看上去感觉有点问题,但是实际上并不会。目前不用担心它。

缩进层级相同

既然已经有了当前行以及下一个非空行的缩进层级,那我们就可以比较它们,并决定如何折叠当前行。

下面再次展示 if 语句:

if next_indent == this_indent
    return this_indent
elseif next_indent < this_indent
    return this_indent
elseif next_indent > this_indent
    return '>' . next_indent
endif

首先,我们检查两行是不是相同的缩进层级。如果是,我们直接把缩进层级作为折叠层级返回!

下面是一个例子:

a
b
    c
    d
e

让我们看看包含“c”的这行,它的缩进层级为1. 它和下一个非空行(“d”)的缩进层级是一样的,所以我们返回的折叠层级为 1

我们再看看“a”这行,它的缩进层级为0。 这和下一个非空行(“b”)的缩进层级是一样的,所以我们返回的折叠层级为 0

在这个简单的例子中,这种情况有2处:

a       0
b       ?
    c   1
    d   ?
e       ?

纯属巧合的是这也能涵盖最后一行特殊“错误”的情况! 记住,我们说过如果辅助函数返回了 -2,那 next_indent 就会是 0

在这个例子中,“e”这行的缩进层级是 0,并且 next_indent 也会是 0,所以这种情况也符合条件,并返回 0。 折叠层级现在就看上去如下:

a       0
b       ?
    c   1
    d   ?
e       0

缩进层级更小

再次展示 if 语句:

if next_indent == this_indent
    return this_indent
elseif next_indent < this_indent
    return this_indent
elseif next_indent > this_indent
    return '>' . next_indent
endif

if 的第二部分检查下一行的缩进层级是否小于当前行的。 在我们的例子中,“d”这行就是这样的。

如果是这种情况,我们还是返回当前行的缩进层级。

现在,我们的例子看上去如下:

a       0
b       ?
    c   1
    d   1
e       0

你当然可以把这两种情况用 || 结合在一起,但是我更喜欢保持分开,使它们更明确。 你可能不这么认为。这只不过是一个风格问题。

再次,纯属巧合的是,这会涵盖其他一些辅助函数的“错误”情况。 假设有如下这样一个文件:

a
    b
    c

第一种情况能处理“b”这行:

a       ?
    b   1
    c   ?

“c”这行是最后一行,它的缩进层级是1。辅助函数会把 next_indent 设为 0。 这匹配 if 语句的第二部分,所以就把缩进层级设置为折叠层级,也就是 1

a       ?
    b   1
    c   1

这产生的效果很好,因为“b”和“c”会折叠到一起。

缩进层级更大

这是最后一次展示 if 语句了:

if next_indent == this_indent
    return this_indent
elseif next_indent < this_indent
    return this_indent
elseif next_indent > this_indent
    return '>' . next_indent
endif

以及我们的样例:

a       0
b       ?
    c   1
    d   1
e       0

只剩下“b”这行还没解决,因为:

最后一种情况检查下一行的缩进层级是否大于当前行。

正是在这里,Vim 的 indent 折叠会出错,而这也是起初我们要自定义折叠方式的全部原因!

最后这种情况是说,当下一行的缩进多于当前行,应该返回一个由 > 字符和一行的缩进层级组成的字符串。 是什么鬼东西?

从折叠表达式中返回一个像 >1 这样的字符串是另一个 Vim 的“特殊”折叠层级。 它告诉 Vim 当前行应当开启一个指定层级的折叠。

这本例中,我们可以只返回数字,但之后会看到为什么这很重要。

在本例中,“b”这行会打开层级为1的折叠,使得我们的样例看上去如下:

a       0
b       >1
    c   1
    d   1
e       0

这正是我们想要的!太棒了!

审查

如果你已经到达这么远了,应该为自己感到自豪。 即使是像这样简单的折叠代码也足够棘手并让人头疼的了。

在结束之前,让我们过一遍之前 factorial.pn 的代码,并看看我们的折叠表达式会如何填写每行的折叠层级。

下面是 factorial.pn, 以供参考:

factorial = (n):
    total = 1
    n to 1 (i):
        # Multiply the running total.
        total *= i.
    total.

10 times (i):
    i string print
    '! is: ' print
    factorial (i) string print
    "\n" print.

首先,任何空行的折叠层级都会被设为未定义(undefined):

factorial = (n):
    total = 1
    n to 1 (i):
        # Multiply the running total.
        total *= i.
    total.
                                         undefined
10 times (i):
    i string print
    '! is: ' print
    factorial (i) string print
    "\n" print.

任何下一行的缩进等于自身缩进的行都被设为自己的层级:

factorial = (n):
    total = 1                            1
    n to 1 (i):
        # Multiply the running total.    2
        total *= i.
    total.
                                         undefined
10 times (i):
    i string print                       1
    '! is: ' print                       1
    factorial (i) string print           1
    "\n" print.

而下一行的缩进小于当前行的也是一样:

factorial = (n):
    total = 1                            1
    n to 1 (i):
        # Multiply the running total.    2
        total *= i.                      2
    total.                               1
                                         undefined
10 times (i):
    i string print                       1
    '! is: ' print                       1
    factorial (i) string print           1
    "\n" print.                          1

最后一种情况是下一行的缩进大于当前行的。 当这种情况发生了,这行的折叠层级就会被设为开启折叠,而这个折叠的层级和一行是一致的:

factorial = (n):                         >1
    total = 1                            1
    n to 1 (i):                          >2
        # Multiply the running total.    2
        total *= i.                      2
    total.                               1
                                         undefined
10 times (i):                            >1
    i string print                       1
    '! is: ' print                       1
    factorial (i) string print           1
    "\n" print.                          1

现在,文件中的每一行都有了折叠层级。唯一剩下的就是 Vim 会如何处理未定义的行。

之前我曾说过,未定义的行会采用邻近行最小的折叠层级。

Vim 手册是这样描述的,但这并不完全准确。 如果按这所述,文件中的空行折叠层级会是1,因为它附近的行都是1。

但实际上,这个空行的折叠层级为是0!

原因就是我们并没有直接把 10 times (i): 这行的折叠层级设为 1。 我们告诉 Vim 这行会开启层级为1的折叠。 而 Vim 足够聪明,知道这意味着未定义的行应该被设为 0 而不是 1

最准确的逻辑可能隐藏在 Vim 源代码的深处。 总之,Vim 在处理与“特殊”折叠层级有关的未定义行时表现得相当明智,它通常都会按照你想要的去做。

一旦 Vim 处理完了未定义行,如何折叠文件中每一行的完整描述就如下所示:

factorial = (n):                         1
    total = 1                            1
    n to 1 (i):                          2
        # Multiply the running total.    2
        total *= i.                      2
    total.                               1
                                         0
10 times (i):                            1
    i string print                       1
    '! is: ' print                       1
    factorial (i) string print           1
    "\n" print.                          1

就这样,我们完工了!重载折叠代码,并在 factorial.pn 中试试新的、奇妙的折叠。

练习

阅读 :help foldexpr

阅读 :help fold-expr。 特别注意折叠表达式中所有可以返回的“特殊”字符串。

阅读 :help getline

阅读 :help indent()

阅读 :help line()

说明为什么在折叠函数中用 . 来连结 > 字符和数字是很重要的。 如果用了 + 会发什么什么?为什么?

我们把辅助函数定义为了全局函数,但这并不是个好主意。 把它们改成脚本本地函数。

放下本书,到外面走走,让你的大脑放松放松。

原文地址:http://learnvimscriptthehardway.stevelosh.com/chapters/49.html