在 Linux 环境中使用 Vim 编辑器的用户,几乎都遇到过这样的尴尬场景:用 vi 或 vim 打开一个文件,费心费力修改了大量内容,最后输入 :wq 准备保存退出,却看到屏幕弹出红色警告——
E45: 'readonly' option is set (add ! to override)
原来文件被设置为只读。你可能会想,加个感叹号强制保存总可以吧?于是输入 :w!,结果又出现提示:
"readonly-file-name" E212: Can't open file for writing
文件明明就在那里,为什么无法写入?查看 Vim 文档 :help E212 之后会发现,根本原因是权限不足。这个文件需要 root 权限才能编辑,而你当初只是用普通用户登录,启动 vim 时忘记加上 sudo。这样一来,之前辛辛苦苦修改的内容难道就要丢失吗?
常见的做法是:先另存为一个临时文件,退出 vim,再用 sudo mv 覆盖原文件。操作起来不算太难,但麻烦的是 Vim 的工作状态——编辑历史、缓冲区内容、撤销链——全都会丢失。如果只想临时保存一下然后继续修改,有没有办法在不退出 Vim 的情况下获取 root 权限来保存文件呢?
解决方案
当然有。只需执行一条 Vim 命令:
:w !sudo tee %
接下来我们拆解分析这条命令究竟做了什么。先查看 Vim 文档 :help :w,找到关于 :w_c 的说明:
*:w_c* *:write_c*
:[range]w[rite] [++opt] !{cmd}
Execute {cmd} with [range] lines as standard input
(note the space in front of the '!'). {cmd} is
executed like with ":!{cmd}", any '!' is replaced with
the previous command |:!|.
The default [range] for the ":w" command is the whole buffer (1,$)
将这个用法对应到我们的命令上:
: w !sudo tee %
没有指定 range,所以默认范围是整个文件。没有指定 opt。后面的 ! 表示后面跟着的外部命令,也就是 sudo tee %。这和直接执行 :!{cmd} 效果一样——在不退出 Vim 的情况下打开 shell 执行一条命令。平时我们常用的 :r !pwd 或 :r !ls 也是同样的原理,它们能把 shell 命令的输出直接读入到当前缓冲区。
注意,这里的 :w 并非真正保存当前文件,它更像“另存为”:把当前缓冲区的所有内容作为标准输入,传给后面的命令。所以整条命令等价于在 shell 中执行:
$ cat readonly-file-name | sudo tee %
其中 % 是什么呢?:help cmdline-special 说得很清楚:在外部命令中,% 会被替换为当前文件的文件名。因此上面的 shell 命令就变成:
$ cat readonly-file-name | sudo tee readonly-file-name
这里可能会有人混淆:在替换命令 :%s/old/new/g 中,% 代表整个文件,而不是文件名。这是两个不同语境下的用法,千万别搞混了。
再说 tee。查看 man 手册可知:它就像一个大写的字母 T,把数据流一分为二——一份写入文件,一份继续流向标准输出。示意图里画得很形象:左边管道传进来的数据经过 tee,右边分叉,一路进文件,一路继续往下传。在我们的命令中,tee 把标准输入的内容写入了 readonly-file-name,另一路标准输出因为没有接收者,相当于被丢弃了。当然你也可以在后面加 > /dev/null 显式扔掉,但没必要多打那几个字符。
执行完这条命令后,Vim 会提示:
W12: Warning: File "readonly-file-name" has changed and the buffer was changed in Vim as well
[O]K, (L)oad File:
建议直接按 O 确认(回车即可),这样 Vim 的工作状态、撤销历史、缓冲区内容都保留下来了。如果选 L 重新加载文件,那就等于打开一个全新的文件,之前的所有修改历史都会丢失。
更省事的方案:做个映射
每次都输入一长串命令毕竟麻烦,可以在 .vimrc 中添加一个快捷映射:
" Allow saving of files as sudo when I forgot to start vim using sudo.
cmap w!! w !sudo tee > /dev/null %
以后直接输入 :w!! 就能达到同样效果。末尾的 > /dev/null 只是显式丢弃标准输出,不写也行。
另一种思路:为什么不用 cat + 重定向?
看到这里你可能会有疑问:这不就是 cat 加上重定向的事情么?为什么不用下面这种更直觉的写法?
:w !sudo cat > %
试试看,结果又是一个错误:
/bin/sh: readonly-file-name: Permission denied
明明加了 sudo,为什么还是说没有权限?问题出在重定向本身。Shell 在执行任何命令之前,会先对重定向进行处理。而当前的 shell 是以普通用户身份运行的,它根本没有权限去打开那个只读文件进行写入。重定向操作不受 sudo 影响,所以错误就发生了。
有人可能会建议换一种写法:
:w !sudo sh -c 'cat > %'
但这里有一个陷阱:% 在单引号内不会被 Vim 展开,而是原封不动传给 shell。而 shell 会把单独的 % 当作空参数,结果文件内容被写到了 nil,保存失败,内容全部丢失。把单引号换成双引号就能解决:
:w !sudo sh -c "cat > %"
因为双引号里的 % 会在传给 shell 之前被 Vim 展开成真实的文件名。同样,这个命令也能映射成简洁的 :w!!:
cmap w!! w !sudo sh -c "cat > %"
注意,这个版本不需要 > /dev/null 了。
两种方案原理相通,都是借助 Vim 能执行外部命令的能力,用不同的 shell 命令绕过了权限限制。如果你还有其他酷炫的玩法,欢迎交流。
