Vim 脚本实战:打造一个支持中文的精准字数统计器

在 VIM 中进行中文写作时,默认的 wcg CTRL-G 统计的是字节数,中文字符会按 3 个字节计算,不符合中文写作“一个字算一个”的习惯。此外,默认状态栏只显示行号、列号,无法实时看到当前字数。

本文提供一套完整的 VIM 脚本,实现:

  • 中文、英文、标点符号统一计为 1 个字符(不含换行)
  • 普通模式显示 从文件开头到光标前 的字数
  • Visual 模式下实时统计 选中区域 的字数(支持三种选择模式)
  • 状态栏显示 已写字数/总字数,类似 1200/5000

完整脚本

将以下代码添加到 ~/.vimrc~/.vim/plugin/wordcount.vim 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
" ==============================================
" Ruler 精准字符统计:中文/英文/标点都算 1 个,不含换行
" 普通模式:统计 开头→光标前
" VISUAL 模式:统计 选中内容
" ==============================================

" 统计整个文件总字符数
function! GetTotalCharCount()
let l:total = 0
for l:line in getline(1, '$')
let l:total += strchars(l:line)
endfor
return l:total
endfunction

" ==============================================
" 普通选择模式 v:统计选中区域字符
" ==============================================
function! GetVisualCharCountNormal()
let l:start = getpos("v")
let l:end = getpos(".")
let l:s_lnum = l:start[1]
let l:e_lnum = l:end[1]
let l:s_col = l:start[2]
let l:e_col = l:end[2]

if l:s_lnum > l:e_lnum || (l:s_lnum == l:e_lnum && l:s_col > l:e_col)
let [l:s_lnum, l:e_lnum] = [l:e_lnum, l:s_lnum]
let [l:s_col, l:e_col] = [l:e_col, l:s_col]
endif

let l:count = 0

if l:s_lnum == l:e_lnum
let l:line = getline(l:s_lnum)
let l:part = strpart(l:line, l:s_col - 1, l:e_col - l:s_col + 1)
let l:count += strchars(l:part)
else
let l:line = getline(l:s_lnum)
let l:part = strpart(l:line, l:s_col - 1)
let l:count += strchars(l:part)

for l:lnum in range(l:s_lnum + 1, l:e_lnum - 1)
let l:count += strchars(getline(l:lnum))
endfor

let l:line = getline(l:e_lnum)
let l:part = strpart(l:line, 0, l:e_col)
let l:count += strchars(l:part)
endif

return l:count
endfunction

" ==============================================
" 行选择模式 V:统计整行字符
" ==============================================
function! GetVisualCharCountLine()
let l:start = getpos("v")
let l:end = getpos(".")
let l:s_lnum = l:start[1]
let l:e_lnum = l:end[1]

let l:from = min([l:s_lnum, l:e_lnum])
let l:to = max([l:s_lnum, l:e_lnum])

let l:count = 0
for l:lnum in range(l:from, l:to)
let l:count += strchars(getline(l:lnum))
endfor
return l:count
endfunction

" ==============================================
" 列选择模式 Ctrl-V:统计方块内字符数(已修复 E46 错误)
" ==============================================
function! GetVisualCharCountBlock()
let l:start = getpos("v")
let l:end = getpos(".")

let l:s_lnum = l:start[1]
let l:e_lnum = l:end[1]
let l:s_col = l:start[2]
let l:e_col = l:end[2]

let l:from_line = min([l:s_lnum, l:e_lnum])
let l:to_line = max([l:s_lnum, l:e_lnum])
let l:from_col = min([l:s_col, l:e_col])
let l:to_col = max([l:s_col, l:e_col])

let l:count = 0

for l:lnum in range(l:from_line, l:to_line)
let l:line = getline(l:lnum)
let l:len_line = strchars(l:line)

if l:from_col > l:len_line
continue
endif

let l:real_end = min([l:to_col, l:len_line])
let l:part = strpart(l:line, l:from_col - 1, l:real_end - l:from_col + 1)
let l:count += strchars(l:part)
endfor

return l:count
endfunction

" 开头到光标前字数
function! GetCursorCharCount()
let l:count = 0
let l:cursor_line = line('.')
for l:line in getline(1, l:cursor_line - 1)
let l:count += strchars(l:line)
endfor
let l:count += strchars(strpart(getline('.'), 0, col('.') - 1))
return l:count
endfunction

" 智能统计:严格区分模式
function! GetSmartCharCount()
let l:m = mode()
if l:m ==# 'v'
return GetVisualCharCountNormal()
elseif l:m ==# 'V'
return GetVisualCharCountLine()
elseif l:m ==# "\x16" " 列模式 Ctrl-V
return GetVisualCharCountBlock()
else
return GetCursorCharCount()
endif
endfunction

" 最终 ruler 显示格式
set rulerformat=%=%l,%c%V\ %P\ %{GetSmartCharCount()}/%{GetTotalCharCount()}

核心基础知识解析

1. VimScript 变量定义与作用域

脚本中大量使用 l: 前缀,如 l:totall:line。这是 VimScript 的局部变量作用域标识:

前缀 含义
l: 函数内部局部变量(最常用)
g: 全局变量
s: 脚本文件局部变量
a: 函数参数

示例:

1
2
3
4
function! Example()
let l:local_var = 10 " 仅本函数内有效
let g:global_var = 20 " 任何地方可访问
endfunction

2. strchars() vs strlen() vs len()

这是实现中英文统一计数的关键函数:

函数 返回值 示例 (你好ab)
strlen() 字节数 8 (UTF-8中文占3字节)
len() 列表长度/字符串字节数 同上
strchars() 字符数 4 (你/好/a/b各1)
1
echo strchars("你好vim")  " 输出 5

3. 字符串比较的 # 后缀

1
if l:m ==# 'v'

==# 表示大小写敏感比较,==? 表示大小写不敏感。不写后缀时会受 ignorecase 选项影响,容易产生难以追踪的 bug。**最佳实践:始终使用 #?**。

4. getpos() 返回值结构

1
2
3
let pos = getpos(".")
" 返回 [bufnum, lnum, col, off]
" 例如: [0, 42, 15, 0]
  • pos[1]:行号
  • pos[2]:列号(从1开始)

脚本中通过 getpos("v") 获取 Visual 模式起始位置,getpos(".") 获取光标当前位置。

5. Visual 模式的三种标识

Vim 中 mode() 函数返回值:

模式 返回值 对应函数
字符选择 v 'v' GetVisualCharCountNormal()
行选择 V 'V' GetVisualCharCountLine()
列选择 Ctrl-V "\x16" (十六进制 22) GetVisualCharCountBlock()

注意列模式不能直接写 "\x16" 比较,需要转义。

6. 字符串切片 strpart()

1
2
strpart("Hello World", 0, 5)   " 返回 "Hello"
strpart("你好世界", 2, 2) " 返回 "世界"

参数说明:

  • 第二个参数:起始位置(字节索引,0开始)
  • 第三个参数:截取长度(字节数,非字符数!)

坑点strpart()字节截取,如果截断在多字节字符中间会乱码。但本脚本与 strchars() 配合使用,且 strchars() 只计算完整字符,所以安全。

7. 列表操作:range() 与解构赋值

1
2
3
4
5
" 生成行号范围
for l:lnum in range(1, 10) " 遍历 1~10

" 解构赋值交换变量
let [l:a, l:b] = [l:b, l:a]

脚本中用此技巧统一选中区域的起止位置。

8. min() / max() 简化边界处理

1
let l:from_line = min([l:s_lnum, l:e_lnum])

比手动 if 判断更简洁。

RulerFormat 与普通状态栏的区别

普通状态栏 (statusline)

statusline 是 Vim 底部倒数第二行,每个窗口独立显示,通常包含文件名、文件类型、Git 分支等丰富信息。典型配置:

1
set statusline=%f\ %m\ %r\ %y\ %{&fileencoding}\ %l/%L\ %c

特点:

  • 默认不显示,需要 set laststatus=2 启用
  • 可以包含动态表达式 %{func()}
  • 支持高亮分组(%#HighlightGroup#
  • 内容较多,占据一整行

Ruler (rulerformat)

ruler 是 Vim 底部最右下角的一小段信息,默认显示光标位置和百分比。特点:

  • 默认开启set ruler
  • 占用空间极小,不干扰编辑区域
  • 格式通过 rulerformat 定制
  • 不能使用高亮分组,功能精简

对比总结

特性 statusline ruler
位置 底部倒数第二行 右下角
默认状态 需手动开启 默认开启
信息容量 大(可占整行) 小(仅右下角)
高亮支持 支持 不支持
表达式支持 %{func()} %{func()}
适用场景 文件信息、模式、Git 光标位置、简单计数

本脚本为何选择 rulerformat

字数统计适合放在右下角,因为:

  1. 它只是一个辅助计数,不需要占用一整行
  2. 与默认的光标位置信息(%l,%c)天然并列
  3. 开启 ruler 的用户不会感到突兀

如果希望更丰富的展示(如目标字数进度条),可以改用 statusline,但需要额外配置 laststatus=2

扩展建议

添加目标字数提醒

GetSmartCharCount() 中加入:

1
2
3
let l:target = 5000  " 目标字数
let l:current = GetSmartCharCount()
echo "已完成 " . (l:current * 100 / l:target) . "%"

保存时自动检查

1
autocmd BufWritePre *.md :echo "当前字数: " . GetSmartCharCount()

使用 statusline 版本(替代方案)

1
2
set laststatus=2
set statusline=%f\ %m\ %r\ %y\ %{GetSmartCharCount()}/%{GetTotalCharCount()}\ %l/%L\ %c

常见问题

Q: 统计结果比 wc -m 少?
A: wc -m 会统计换行符,本脚本不统计换行。

Q: 列模式统计出错 E46?
A: 已修复。原因是 Vim 列模式返回值需要特殊处理十六进制 \x16

Q: 中英文混合的 emoji 如何计数?
A: strchars() 将 emoji (如 👍) 计为 1 个字符,符合直觉。

总结

本脚本利用 VimScript 的 strchars() 实现了统一字符计数,通过 mode() 区分三种 Visual 模式,最终通过 rulerformat 优雅地展示在界面右下角。掌握这些基础知识后,你可以轻松改造状态栏、实现字数目标、多文件统计等进阶功能。