↓ 跳过正文

shfmt:为 Shell 脚本建立稳定的自动格式

·3884 字·8 分钟·
目录
modern-cli - 这篇文章属于一个选集。
§ 17: 本文

阅读信息

  • 内容类型:教程
  • 难度:入门
  • 适合谁:希望在 Linux、WSL2 与远程服务器上提升命令行效率的开发者。
  • 读完后:选择明确的 Shell 方言与缩进策略,用 shfmt 检查和写回文件,并与 ShellCheck、watchexec 组成质量闭环。
  • 依据版本:shfmt 3.14.1
  • 验证日期:2026-09-11

Shell 语法允许极高的排版自由度:then 与 do 可以放在行尾或另起一行,分支语句缩进各异,管道与逻辑运算符换行位置各不相同。缺乏统一格式规范时,多人协作与代码审查会充斥无意义的缩进与换行争论。

shfmt 提供确定性的格式化契约:

可解析 Shell 源码 → 结构化语法树 (AST) → 应用格式策略 → 幂等稳定输出

shfmt 的边界非常明确:它只处理代码的排版和语法树对齐,不会证明变量引用是否安全、命令是否存在,也不会修正逻辑错误。本文以 shfmt 3.14.1 为规范与验证基准。

为 shfmt 固定 Shell 方言和格式策略,先预览差异、再在受控范围写回,并结合 ShellCheck 与行为测试验收
图:先用明确策略预览并审查 diff,再只写回自有范围;格式一致仍需静态检查和行为测试。

1. 为什么 Shell 脚本需要自动格式化
#

在持续集成和 Git 协作中,未格式化的脚本通常带来两类实际损耗:

  1. 审查噪声膨胀:不同开发者的编辑器缩进设置(Tab、2 空格或 4 空格)不一致,每次提交都会引入大量无关空白字符变更,掩盖真正的业务逻辑修改。
  2. 生成式代码排版漂移:由不同自动化工具或辅助编程环境生成的脚本往往带有风格迥异的分支换行与对齐方式,难以进行批量审查。

shfmt 是基于 Go 官方 gofmt 理念构建的格式化工具,具备幂等性(对同一文件多次执行输出完全一致)。它的核心定位是消除格式争论,提供可复现的排版基线。

需要明确采用前提:shfmt 必须先成功解析目标脚本的 AST。如果脚本本身存在严重语法错误,或方言配置与代码实际语法不匹配,格式化将直接中断并报错。

2. 安装与环境确认
#

通过包管理器、官方 Release 或 Go 环境安装后,首先验证版本:

shfmt --version

基准实测输出:

v3.14.1

默认情况下,shfmt 会将格式化后的代码输出到标准输出(stdout),而不直接覆写原文件:

shfmt script.sh

若需要在管道中处理动态生成的 Shell 片段,可以通过标准输入传递:

printf '%s\n' 'if [ "$1" ]; then echo ok; fi' | shfmt -i 2

输出:

if [ "$1" ]; then
  echo ok
fi

这种非侵入式设计便于在不改变工作区的前提下验证排版规则。

3. 端到端 Before / After:排版契约与核心风格参数
#

本节使用一段具有常见格式混乱特征的真实脚本,演示排版前后的确定性变化。可直接在本地创建脚本,或进入仓库附带的 fixture 目录复现:

cd research/modern-cli/fixture

格式化前的混乱输入
#

查看 script.sh:

#!/usr/bin/env bash
if [[ -n "$name" ]] &&
[[ -f "$name" ]];then
case "$name" in
*.sh)echo shell;;
*)echo other;;
esac
fi

这段代码存在缩进缺失、运算符断行混乱、分号缺少空格分隔以及 case 分支与主体完全平齐的问题。

执行格式化指令
#

使用明确的方言与风格参数运行 shfmt:

shfmt -ln bash -i 2 -bn -ci script.sh

格式化后的规范输出
#

shfmt 3.14.1 输出如下标准结果:

#!/usr/bin/env bash
if [[ -n "$name" ]] \
  && [[ -f "$name" ]]; then
  case "$name" in
    *.sh) echo shell ;;
    *) echo other ;;
  esac
fi

关键排版参数解析:

  • -ln bash(--language-dialect):声明使用 Bash 方言解析器,允许识别 [[ ... ]] 等 Bash 独有语法;
  • -i 2(--indent):使用 2 个空格作为缩进单位。shfmt 默认使用 Tab(相当于 -i 0);
  • -bn(--binary-next-line):当二元逻辑运算符(如 &&、||)断行时,将其统一放置在下一行行首,并在上一行末尾添加续行反斜杠;
  • -ci(--case-indent):对 case 模式分支进行相对缩进,而不是让模式行与 case/esac 关键字平齐;
  • 关键字间距自动修复:将紧凑的 ;then 规范化为 ; then,并在 ;; 终止符前补齐空格。

这些参数代表了团队工程协作的一种明确基准。项目应当统一设定一次,并在所有流水线中一致执行。

4. 工作流三模式:-d、-w、-l 与退出状态
#

在不同工作场景中,应选择对应的模式参数:

参数运行模式标准输出退出状态码推荐适用场景
-d--diff打印 Unified Diff 格式差异有差异返回 1,无差异返回 0CI 门禁检查、合并请求质量验收
-l--list仅输出不符合格式的文件路径存在不合规文件返回 1,全合规返回 0Git 预提交钩子(pre-commit)
-w--write无(直接写回文件)成功返回 0,语法解析失败返回 1本地手动格式化、编辑器保存自动处理

CI 门禁检查示例
#

在持续集成环境中,检查代码格式并阻止未格式化提交:

shfmt -d -ln bash -i 2 src
printf 'exit=%s\n' "$?"

若存在格式差异,shfmt 打印差异行并退出为 1,直接阻断构建。

本地受控写回流程
#

在批量格式化前,务必遵循规范工作流:

# 1. 确认工作区干净
git status --short

# 2. 先行预览差异
shfmt -d -ln bash -i 2 src

# 3. 确认无误后执行写回
shfmt -w -ln bash -i 2 src

# 4. 审查实际 Git 差异
git diff -- src

切忌在包含大量未提交修改或长期活跃分支的仓库中盲目对根目录运行 shfmt -w .。全量格式化会导致后续分支合入时产生大量冲突,并破坏 git blame 的历史上下文追踪。

5. 用 .editorconfig 固化团队格式策略
#

频繁在命令行中拼接 -ln bash -i 2 -bn -ci 极易因参数遗漏导致格式反复横跳。shfmt 原生支持读取 .editorconfig 配置。

在项目根目录创建或合并 .editorconfig:

root = true

[*.sh]
shell_variant = bash
indent_style = space
indent_size = 2
binary_next_line = true
switch_case_indent = true
space_redirects = true

完成配置后,在任意子目录直接执行:

shfmt -d .

shfmt 会自动识别匹配文件的方言、缩进方式与换行风格,使本地命令行、VS Code/Neovim 插件与 CI 跑道共享同一份不可篡改的排版契约。

目录遍历与排除规则: 通过目录参数调用 shfmt 时,建议配合 --apply-ignore 参数,让 shfmt 自动遵循项目的 .gitignore,避免误触 .git 内部对象或 vendor 目录。

6. 方言解析与失败条件
#

shfmt 必须先构建 AST 才能完成格式化。不同方言对应的语法树解析规则各不相同:

shfmt -ln bash script.sh
shfmt -ln posix script.sh
shfmt -ln mksh script.sh

方言不匹配导致的解析中断
#

若一个脚本使用了 Bash 特性(例如 [[ -f $file ]]),但在检查时传递了 POSIX 方言:

shfmt -ln posix script.sh

输出报错信息:

script.sh:2:4: unexpected [[, expected statement

进程退出码为 1。

排错原则:

  1. 当 shfmt 报错 unexpected ... 时,优先核实代码首行的 shebang 是否与解析方言一致;
  2. 如果脚本设计在 POSIX /bin/sh(例如 Alpine 系统中的 BusyBox ash)下运行,该报错是宝贵的缺陷信号,说明脚本误用了 Bash 独有语法;
  3. 不要为了通过格式化而随意将方言改为 bash,除非确认生产环境的真正解释器确为 Bash。

7. 语法边界与人工复查重点
#

自动格式化工具无法理解文本背后的业务语境。在对包含以下特征的脚本运行格式化后,必须进行人工复查:

Heredoc 内容边界
#

cat <<EOF
    SELECT *
    FROM users;
EOF
  • 在 <<EOF 块中,shfmt 会严格保留内容区的空白字符与换行,不会擅自修改 SQL 或内嵌配置的内部缩进;
  • 在使用 <<-EOF 时,Shell 规范允许剥离每行开头的制表符(Tab),但不剥离空格。若人工误将制表符改为空格,会导致内嵌文本格式被破坏;
  • 结尾定界符 EOF 必须保持语法规范要求的位置,不能随意增加前置空格。

嵌入式脚本与模板文本
#

如果脚本内部通过引号或管道嵌入了大段 awk、sed、Python 或 JSON 模板,shfmt 只负责外层 Shell 调用的缩进,内部字符串的格式化超出其能力范围,必须人工核对语义是否受损。

语法简化参数 -s 的采用边界
#

shfmt 提供了 -s(--simplify)选项,用于自动消除冗余语法结构(如将多余的括号或重复的测试操作符简化)。

-s 属于 AST 级别的代码结构重写,其变更性质比纯粹的空白字符调整更加激进。工程建议:

  1. 初次引入 shfmt 时仅使用格式化参数(-d / -w);
  2. 若需开启 -s,应作为独立的重构任务提交,并在完善的自动化行为测试覆盖下进行审查。

8. 质量闭环:与 ShellCheck、watchexec 及 CI 的分工
#

格式化只是代码质量的第一步,整洁的代码不等于正确的代码。各工具在现代 CLI 工具链中的分工如下:

工具关注维度核心职责验证方式
shfmt代码风格与排版保证缩进、换行、操作符排版统一静态 AST 格式化与 diff 比较
ShellCheck静态语义与缺陷识别未加引号的变量、作用域泄漏、可移植性问题静态规则引擎(SC 编号)
bash -n语法解析有效性验证当前环境解释器能否构建语法树解释器空运行解析
行为测试运行时动态逻辑验证文件操作、退出码、边界输入输出是否符合预期容器或测试脚本动态执行

本地监听反馈循环
#

结合 watchexec,在保存脚本时自动完成“格式校验 + 静态分析”双重门禁:

watchexec -e sh -w src -- \
  bash -c 'shfmt -d -ln bash -i 2 src/*.sh && shellcheck src/*.sh'

CI 门禁集成
#

在持续集成脚本中直接加入校验步骤:

# CI 步骤:格式不合规或静态检查有严重警告直接中断构建
shfmt -d -ln bash -i 2 src/*.sh
shellcheck --severity=warning src/*.sh
bash tests/run.sh

9. 渐进式迁移现有仓库的操作步骤
#

对已有较多 Shell 脚本的存量项目,切忌一次性全量覆写。建议按以下步骤平滑落地:

  1. 盘点资产与方言:扫描仓库所有 .sh 文件及含有 shebang 的脚本,统计其目标解释器(bash、sh 等);
  2. 制定并提交 .editorconfig:团队就缩进风格达成共识,将配置写入仓库根目录;
  3. 分模块试跑 diff:针对核心或由当前团队维护的目录运行 shfmt -d .,评估改动幅度;
  4. 排除第三方与生成文件:在 .gitignore 中完善构建产物与 vendor 路径,并使用 --apply-ignore;
  5. 单独提交格式化变更:创建专用的提交(如 style: format shell scripts with shfmt),不要与业务功能变更混合;
  6. 配置 Git Blame 忽略:在仓库中添加 .git-blame-ignore-revs 并记录该次格式化提交哈希,防止破坏历史作者追踪;
  7. 配置 CI 门禁:在 PR 检查中强制执行 shfmt -d,防止新增代码重新引入风格漂移。

10. 可执行练习与验收
#

完成以下练习以检验对 shfmt 的操作理解:

  1. 创建格式混乱的脚本:在临时目录编写 format_lab.sh:
    #!/usr/bin/env bash
    if [[ "$1" == "start" ]];then
    echo "starting service"
    case "$2" in
    fast)echo "fast mode";;
    *)echo "normal mode";;
    esac
    fi
  2. 差异检查与状态码验证:
    • 运行 shfmt -d -ln bash -i 2 -ci format_lab.sh;
    • 观察输出的 Unified Diff;
    • 运行 echo $?,确认退出码为 1。
  3. 就地写回:
    • 运行 shfmt -w -ln bash -i 2 -ci format_lab.sh;
    • 检查文件内容,确认 then、case 分支及操作符已被规范排版。
  4. 再次验收:
    • 重新执行 shfmt -d -ln bash -i 2 -ci format_lab.sh;
    • 确认无任何输出,且 echo $? 为 0。
  5. 触发方言解析错误:
    • 执行 shfmt -ln posix format_lab.sh;
    • 观察对 [[ ... ]] 报出的语法解析失败信息及退出码。

参考资料
#


本文采用 CC BY-NC-SA 4.0 许可。

modern-cli - 这篇文章属于一个选集。
§ 17: 本文

相关文章