Shell脚本学习指南-第十二章-拼写检查
第12章 拼写检查
本章背景:讲解UNIX拼写检查工具的演变:从最早管道原型 →
spell→ispell/aspell→ 完整的awk实现拼写检查器。 核心思想:UNIX工具哲学,用小工具管道组合完成任务;awk强大的文本处理,不到200行实现拼写检查。 用到命令:tr sort uniq comm;shell脚本参数处理;awk函数、关联数组、getline、match/sub/gsub、ARGV/ARGC/FILENAME/FNR内置变量。
12.1 spell 程序
作用
读取文件,输出字典中找不到的单词列表(疑似拼写错误)。
⚠注意:POSIX没有标准化
spell,不是所有系统自带。
简单应用示例
脚本里嵌入拼写检查:
|
|
12.2 最初的UNIX拼写检查原型(管道版本)
1975年,最早spell原型完全由UNIX管道组合实现,没有写C程序。
工具知识点:comm
前提:两个输入必须预先排序 语法:
comm [-123] file1 file2,其中-代表读取标准输入 输出三列:
- 只在file1存在
- 只在file2存在
- 两个文件都存在
| 选项 | 作用 |
|---|---|
-1 |
不显示第1列 |
-2 |
不显示第2列 |
-3 |
不显示第3列 |
示例 comm -13 dictionary -
-1:屏蔽仅字典有的;-3屏蔽两边共有的;- 只剩下 只出现在管道输入(待检查文件) 的单词,就是疑似错拼单词。
prepare是预处理工具,作用剔除标记(roff/html标记),简单场景直接用cat。
comm 命令详解
核心基础
comm file1 file2
- 要求:file1、file2 两个文件必须提前排序(sort),否则结果完全乱掉。
- 输出固定3列,用tab制表符隔开:
| 列号 | 含义 |
|---|---|
| 第1列 | 仅在 file1 里面出现的行 |
| 第2列 | 仅在 file2 里面出现的行 |
| 第3列 | file1 和 file2 两者都有的行 |
⚠重点:
-1 -2 -3选项,数字代表【列编号】,不是文件编号!-1→ 不输出第1列-2→ 不输出第2列-3→ 不输出第3列
不是控制文件file1/file2;是控制输出结果里的三列要不要打印出来。
举个简单手工例子
文件A(file1)
文件B(file2)
两个都已经排好序。
执行:
|
|
输出(tab分隔三列):
拆解输出:
apple→ 第1列:只在A(file1)banana→第3列:A、B都有cat→第1列:只在A(file1)dog→第2列:只在B(file2)egg→第2列:只在B(file2)
测试选项
comm -13 A B
-1:关掉第1列(仅file1的行不输出)-3:关掉第3列(两边共有的行不输出) 只剩下第2列:只存在于file2的行。
输出结果:
符号 - 的含义
comm 的两个参数位置
comm file1 file2,其中任意一个位置写横杠-,代表:这个位置不从磁盘读文件,改为读取【标准输入(stdin,管道过来的数据)】。
回到书上拼写检查的管道命令:
|
|
参数对应:
- 第一个参数:
dictionary→ file1 = 字典文件(磁盘上真实文件,已经sort排序) - 第二个参数:
-→ file2 = 读取管道标准输入(前面管道输出的单词流)
这里: file1 = dictionary(字典)
file2 = 管道传过来待检查的单词
comm -13 dictionary -
-1:关闭【第1列】:只在file1(字典)里面有的单词 → 不打印-3:关闭【第3列】:字典和待检查两边都有的单词 → 不打印- 剩下:第2列:只存在file2(待检查文本)、不在file1(字典)的单词 → 就是疑似拼错的单词!
表格对应书上示例
| comm列 | 代表内容 | ‑13操作 |
|---|---|---|
| 第1列 | 只在字典(file1)中的单词 | ‑1,屏蔽不输出 |
| 第2列 | 只在待检查文本(file2)中的单词(错拼) | 保留输出 |
| 第3列 | 字典、待检查文本两者都有的正确单词 | ‑3,屏蔽不输出 |
一句话记忆:
-1 -2 -3的数字是输出的列号,不是文件号;- 参数位置上的
-横杠,代表该位置读取管道/标准输入,不是磁盘文件。
完整管道回顾
管道前面一连串输出,送到comm的第二个参数位置(就是那个-),充当file2;磁盘文件dictionary充当file1。
12.3 改良的 ispell 与 aspell
原生UNIX spell支持私有用户字典:+字典文件
|
|
+后面接私有字典:存放专业术语、专有名词,这些单词不在系统字典,但属于正确单词。
spell 的坑
- 仅支持一个私有字典;
- 私有字典必须是已经排序好;
- 受
locale环境影响,locale改变,排序逻辑变化,spell会异常,同一个文件报错数量会变化。
解决:执行spell前,设置
LC_ALL=C保证字典和程序的locale一致。
ispell / aspell
两个流行开源拼写检查工具:
ispell:老牌交互拼写检查;aspell:拼写纠错建议更好,目标替代ispell。
重要选项(两者通用)
| 选项 | 作用 |
|---|---|
-l |
批处理,输出所有疑似错误单词(输出不排序、不去重) |
-p file |
指定个人私有字典 |
问题:原生输出未排序、重复,和传统spell行为不一致,需要shell脚本包装。
包装1:模拟spell,使用ispell
逻辑:
- 如果第一个参数以
+开头,提取字典文件名,转为ispell的‑p参数; cat "$@"读取全部输入文件;交给ispell;sort -u排序去重,模拟spell输出。
脚本解析
逐点拆解脚本
- 关于参数
+/usr/local/lib/local.words
调用脚本的时候:
|
|
$1就是第一个参数:+/usr/local/lib/local.words(加号和路径是一整个参数,不拆开)$2就是test.txt
你的理解:$1 保存完整的 +/usr/local/lib/local.words,是正确。
case $1 in +?*)
这是 shell 的模式匹配(不是正则),写在 case 里面。
+:字面字符,匹配参数以加号+开头?:shell glob,匹配任意1个字符*:shell glob,匹配0个或者任意多个字符
合起来:+?*
匹配:以
+开头,并且加号后面至少还要有一个字符。 也就是识别+filename这种格式;单独一个+不会匹配。
你理解的:加号开头后面跟任意字符 ✔,
?保证不能只有一个孤零零的加号。
${1#+}—— 参数展开,不是命令替换!!重点区分 ,${1#+}等价于${$1#+}是错误!不能写美元符号在大括号里面的数字上,大括号内直接写数字代表位置参数。
分清两个极易搞混符号
- 命令替换:
$(...)美元括号,执行命令拿输出。 - 参数/变量展开:
${var#pattern}大括号,对变量字符串做删除,不启动任何子进程、不执行外部命令。
语法:${变量#模式}
#:从字符串开头,删除最短匹配的模式。
例子:
${var#+}:删掉变量开头的字面字符 +,得到结果:
/usr/local/lib/local.words
回到代码:
|
|
$1 值是 +/usr/local/lib/local.words
${1#+} → 删除开头的 +,得到纯路径 /usr/local/lib/local.words。
然后:
|
|
变量变成:mydict="-p /usr/local/lib/local.words",后面直接给 ispell 使用。
对比记忆
| 写法 | 类型 | 作用 |
|---|---|---|
$(cmd) |
命令替换 | 运行cmd,捕获输出结果 |
${var#xxx} |
变量展开 | 对变量字符串头部删除匹配文本,不执行命令 |
shift
匹配成功执行 shift;
shift:把位置参数左移一位。原来的
$2变成新$1。
调用示例:./myspell +dict.txt a.txt b.txt
- shift 执行前:
$1=+dict.txt $2=a.txt $3=b.txt - shift 执行后:
$1=a.txt $2=b.txt>
把带
+的字典参数消耗掉,剩下的都是待检查的文件名。
5 cat "$@"
$@:全部位置参数;加双引号"$@",每一个参数作为独立一项,文件名带空格也不会错乱。
shift之后,$@里面就是用户传入待检查的全部文件列表。
cat "$@" 读取所有这些文件的内容,输出到管道。
把完整调用走一遍
调用命令:
|
|
$1=+/usr/local/lib/local.words,case匹配+?*mydict=${1#+}→mydict=/usr/local/lib/local.wordsmydict="-p $mydict"→mydict="-p /usr/local/lib/local.words"shift:参数移位;现在$1=note.txtcat "$@"→cat note.txt,读取文件内容输出管道- 管道交给:
ispell -l -p /usr/local/lib/local.words - 输出交给
sort -u排序去重。
如果调用脚本不带 +xxx 参数:case不匹配,mydict保持为空。 那管道就变成:
cat "$@" | ispell -l | sort -u,不传递-p私有字典。
思考题
输出结果是什么?
答案 hello.world
关键小结
+?*)case glob模式:匹配以+开头,加号后面至少有一个字符的参数。${1#+}:变量展开,不是命令替换,删除字符串开头的+。shift:把已经处理完的字典参数扔掉,剩下的就是待检查文件名。"$@":全部剩余文件名,传给cat读取。- 原生
spell +dict file的接口,通过脚本翻译成ispell -l -p dict的接口。>
这就是shell包装脚本的价值:把旧程序的参数格式,翻译成新程序能识别的格式。
包装2:模拟spell,使用aspell
注意:aspell的
‑p要求字典是编译二进制,不能直接用普通文本字典。 变通思路:aspell输出错词后,用fgrep -v -f 私有字典,把私有字典内单词从报错列表剔除。
--mode=none:关闭html/tex标记过滤;eval $mydict:没有私有字典就执行cat原样输出;有私有字典执行fgrep -v -f file,剔除私有字典内单词。
12.4 awk实现拼写检查程序 spell.awk
目标:纯awk实现拼写检查,规避原生spell缺陷:字典不需要预先排序,不受locale排序影响。
设计目标摘要
- 输入文本,输出不在字典中的疑似错误单词;
- 支持系统字典、用户追加私有字典;
- 字典无需排序;
- 大小写不敏感查找,输出保留原文大小写;
- 忽略标点、数字;支持缩写撇号
'; - 输出排序去重;
-verbose:输出文件名:行号:单词,方便定位;-strip:后缀剥离(比如jumping → jump,处理单词变形,减少误报)。
调用示例:
输出示例(普通模式)
verbose模式输出
整体程序框架(三部分,awk标准模式)
核心函数拆解
1. initialize()
做全部初始化工作:
NonWordChars:正则,匹配非单词字符,后续用来剔除标点符号;get_dictionaries()获取字典文件列表;scan_options()解析命令行选项(‑strip、‑verbose);load_dictionaries()读取字典文件;load_suffixes()加载后缀剥离规则;order_suffixes()后缀规则按长度从长到短排序(优先匹配长后缀)。
2. get_dictionaries()
字典来源优先级:
- awk变量
Dictionaries(‑v Dictionaries="a.txt b.txt"); - 环境变量
DICTIONARIES; - 以上都没有,使用系统默认字典。
技巧:字典文件名作为数组下标
DictionaryFiles["/usr/dict/words"]++
- 自动去重:同一个字典多次传入,数组key只会存一份;
- 遍历直接用
for(key in DictionaryFiles)。
3. load_dictionaries()
Dictionary关联数组:key=小写单词,value随便(只用来判断key是否存在);- 判断单词是否合法:
if(lc_word in Dictionary)。
awk getline word < file 详解
awk getline word < file详解
你的理解大体正确:
getline用来读取磁盘文件file的一行文本,读到的行存入变量word。
读到文件末尾,就返回0,循环退出;发生错误返回负数。
getline 变量 < 文件 语法
|
|
- 打开磁盘上的
file文件; - 读取一行,去掉末尾换行符,放到变量
word; - 返回值(非常重要)
| 返回值 | 含义 |
|---|---|
>0(等于1) |
成功读到一行;继续循环 |
0 |
读到文件末尾 EOF,没有更多行 |
<0(-1) |
出错:文件不存在、没有权限等 |
while条件
|
|
- 只要返回值大于0(成功读到一行),就执行循环体;
- 返回
0(文件读完) → while条件不成立,跳出循环; - 如果文件打不开返回
-1,也直接跳出循环。
外面两层括号
((... )>0):内层括号把getline作为整体,拿它的返回值和0比较。
完整流程走一遍
假设字典文件words内容:
getline word < file→读到Apple→word="Apple",返回1;tolower(word)变成apple;Dictionary["apple"]++,数组key保存单词。- 再次循环:getline读下一行
Banana→ word=“Banana”,存入Dictionary["banana"] - 再次循环:getline读
Cat→存入Dictionary["cat"] - 再次执行
getline word < file:已经到文件结尾,返回0;0>0为假,退出while循环。 close(file):❗必须关闭文件。>
awk不会自动关闭
getline < file打开的文件;不close,打开文件句柄会耗尽,多次调用会bug。
重点区分两套完全不一样的读取(极易混淆)
①普通awk自动读取(主输入流,不用写getline)
|
|
awk自动循环读test.txt每一行,存入$0,这是awk默认工作模式。
②getline var < filename 手动读取磁盘文件(本案例)
这是手动主动打开另外一个磁盘文件,和awk主输入文件无关!
在这个load_dictionaries函数里面:awk正在处理待检查文本;同时用getline额外读取字典磁盘文件,加载单词表。
- 读到的内容放到自定义变量
word,不会修改 $0 $1 NF 这些字段!
⚠关键点:
word是自定义变量,不是$0。getline var这种形式,不会更新$0、NF、NR、FNR。
易错点
getline word < file,< file这里的file是awk变量,不要加引号。file存文件名字符串;- 不能写
getline word < "file",那会打开名叫字面字符串file的文件。
- 用完一定要
close(file);反复循环打开大量文件,不关闭会报“too many open files”。 - 判断条件是
>0,不要写!=0。因为出错返回负数,-1 !=0也成立,会进入循环,程序异常。
标准写法:while ((getline word < file) > 0),只处理成功读到行的情况。
简短总结
getline word < file:打开file,读取一行放到变量word;返回1成功,0到文件末尾,负数出错。while ((getline word < file) >0):循环读取全部行,读到文件末尾退出循环。- 这是手动读取额外磁盘文件,和awk主输入流无关,不改变
$0。 - 读取完成务必
close(file)关闭文件。
4. scan_options()解析选项,修改ARGV/ARGC
awk内置变量:
ARGV[]:命令行参数数组;ARGC参数总个数。
技巧:把选项对应的
ARGV[k]设置为空字符串,awk就不会把它当做待读取的输入文件; 最后清理,去掉数组末尾空参数,避免awk错误读取标准输入。
识别选项:
-strip:开启后缀剥离;-verbose:开启带行号输出;=xxx.sfx:后缀规则文件。
5. load_suffixes() 加载后缀剥离规则文件
后缀规则示例片段english.sfx
逻辑:
- 读行;去掉注释
#、行首尾空白,跳过空行; split()切割,正则存入Suffixes[],替换文本存入Replacement[]。
如果用户没有提供后缀文件,程序内置一套英文默认后缀。
6. order_suffixes() 冒泡排序后缀规则
⚠重要:后缀必须长的优先匹配。例如不能先匹配短的
s$,再匹配长的ings$。
- 将后缀正则从数组key转为有序数组
OrderedSuffix[1...N]; - 冒泡排序,按字符串长度降序排列;
swap(a,i,j)辅助函数交换数组元素。
7. spell_check_line() 处理每一行文本
gsub把非单词字符全部清空,一行文本被分割成字段$1 $2 … $NF;- 剔除单词首尾缩写撇号;
- 调用
spell_check_word()做单词检查。
gsub、sub、NF详解
awk:gsub、sub、NF、正则 ^'+
NF内置变量
NF = Number of Fields,当前这一行的字段总数
awk 默认按空白(空格、tab)切割一行文本,切割之后:
$1:第 1 个字段$2:第 2 个字段- …
$NF:最后一个字段NF:一共有多少个字段。
sub()与gsub()
两者都是字符串替换函数。
| 函数 | 全称 | 行为 |
|---|---|---|
sub(regex, replacement [,target]) |
substitute | 只替换【第一个】匹配到的内容 |
gsub(regex, replacement [,target]) |
global substitute | 全局,全部匹配都替换;g=global |
语法:
例子 1 gsub(代码第一行)
gsub(NonWordChars, "")
- 没有写第三个参数,默认操作
$0当前整行; - 把所有匹配
NonWordChars正则的字符,全部替换为空字符串(直接删掉)。
作用:把一行里面所有非单词字符全部清除。
例子 2 sub
|
|
- 在变量
word上操作; - 找到匹配正则的内容,只替换第一个匹配,替换成空(删除)。
|
|
- 在变量
word上操作; - 找到匹配正则的内容,只替换第一个匹配,替换成空(删除)。
- 正则表达式
"^'+拆解
|
|
表格
| 符号 | 含义 |
|---|---|
^ |
正则锚点:字符串开头 |
' |
字面的撇号字符(单引号) |
+ |
量词:前面的字符,出现 1 次或者多次 |
^'+ = 字符串开头,连续一个或多个撇号 '。
示例:
- 单词
'''hello:开头有 3 个撇号 → 匹配成功,全部删掉 → 得到hello don't:撇号在中间,不是开头,不会匹配,保留don't
8. spell_check_word(word) 单词检查核心
|
|
FILENAME:当前正在处理的文件名;FNR:当前行号;Exception关联数组:保存疑似错误单词,供END阶段输出。
9. strip_suffixes() 后缀剥离
输入一个小写单词,按已经排好序(长优先)的后缀正则逐个match()匹配:
- 匹配到后缀:截取得到词根;
- 根据规则生成候选词根/变形,存入
wordlist[]; - 匹配到第一条后缀立刻break,不再尝试更短后缀。
返回wordlist给上层,上层判断:如果任意候选词根存在字典,该单词视为正确。
10. report_exceptions() END块输出结果
awk支持把print输出直接管道交给外部
sort命令,不用自己实现复杂排序。
- verbose模式:按文件名、数字行号排序;普通模式按单词忽略大小写排序;
close(sortpipe):必须关闭管道,否则程序不会结束。
spell.awk关键知识点汇总
- awk关联数组做哈希字典:
key in array快速判断存在; getline < file读取字典文件;match() substr()字符串截取;sub gsub split()文本处理;- awk内置变量:
ARGV ARGC FILENAME FNR; - awk可以直接把print输出管道交给外部shell命令;
- 后缀匹配:长后缀优先匹配,需要手动排序;
- 工程思想:排序交给外部sort,不要在awk内部重写复杂排序逻辑。
性能与工程思考
- 190行awk,实现完整拼写检查;速度接近C写的工具;
- 字典、后缀规则全部是普通文本,方便编辑;
- 不做标记去除(html/roff),遵循UNIX哲学:单独写工具预处理输入,本工具只负责拼写检查;
- awk的profile工具
pgawk,可以生成性能分析报告,定位程序热点代码。
12.5 小结
- 最早UNIX拼写检查完全依靠管道工具组合实现,展现小工具组合的强大。
spell存在locale、字典必须排序的坑;ispell/aspell批处理输出需要shell脚本包装,模拟传统spell。- awk版本拼写检查:利用关联数组实现哈希字典,规避字典排序、locale问题;支持后缀剥离减少误报。
- UNIX工具思想:复杂工作交给成熟外部工具(sort),自己只做业务逻辑,不重复造轮子。
- shell脚本常见用途:包装已有程序,微调输入输出,适配自己需要的接口。
文章作者 会写代码的小郎中
上次更新 2011-07-25
许可协议 CC BY-NC-ND 4.0