lint-shell.sh 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. #!/usr/bin/env bash
  2. #
  3. # Lint shell scripts in this repository.
  4. #
  5. # Usage:
  6. # scripts/lint-shell.sh [--all] [--format] [--strict] [file ...]
  7. #
  8. # By default, runs ShellCheck and shell syntax checks on changed shell scripts.
  9. # Use --format to format with shfmt before linting. Use --all for the full tracked
  10. # baseline, or pass files explicitly to lint a smaller set.
  11. set -euo pipefail
  12. usage() {
  13. sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
  14. }
  15. die() {
  16. echo "error: $*" >&2
  17. exit 1
  18. }
  19. require_tool() {
  20. command -v "$1" >/dev/null 2>&1 || die "required tool '$1' is not on PATH"
  21. }
  22. is_shell_file() {
  23. local path="$1"
  24. local first_line=""
  25. [[ -f "$path" ]] || return 1
  26. case "$path" in
  27. *.sh)
  28. return 0
  29. ;;
  30. esac
  31. IFS= read -r first_line <"$path" || true
  32. [[ "$first_line" =~ ^#!.*[/[:space:]](bash|dash|ksh|sh)([[:space:]]|$) ]]
  33. }
  34. ensure_git_work_tree() {
  35. git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
  36. || die "run this from inside a git work tree, or pass files explicitly"
  37. }
  38. add_shell_file() {
  39. local path
  40. local existing
  41. path="$1"
  42. if ! is_shell_file "$path"; then
  43. return 0
  44. fi
  45. if [[ "${#files[@]}" -gt 0 ]]; then
  46. for existing in "${files[@]}"; do
  47. if [[ "$existing" == "$path" ]]; then
  48. return 0
  49. fi
  50. done
  51. fi
  52. files+=("$path")
  53. }
  54. collect_all_shell_files() {
  55. local path
  56. ensure_git_work_tree
  57. while IFS= read -r -d '' path; do
  58. add_shell_file "$path"
  59. done < <(git ls-files -z)
  60. }
  61. collect_changed_shell_files() {
  62. local path
  63. ensure_git_work_tree
  64. if git rev-parse --verify HEAD >/dev/null 2>&1; then
  65. while IFS= read -r -d '' path; do
  66. add_shell_file "$path"
  67. done < <(git diff --name-only -z --diff-filter=ACMR HEAD)
  68. while IFS= read -r -d '' path; do
  69. add_shell_file "$path"
  70. done < <(git diff --cached --name-only -z --diff-filter=ACMR)
  71. else
  72. collect_all_shell_files
  73. fi
  74. while IFS= read -r -d '' path; do
  75. add_shell_file "$path"
  76. done < <(git ls-files --others --exclude-standard -z)
  77. }
  78. collect_requested_shell_files() {
  79. local path
  80. for path in "$@"; do
  81. add_shell_file "$path"
  82. done
  83. }
  84. syntax_shell_for() {
  85. local path="$1"
  86. local first_line=""
  87. IFS= read -r first_line <"$path" || true
  88. case "$first_line" in
  89. *"/sh"* | *" env sh"* | *"/dash"* | *" env dash"*)
  90. printf 'sh'
  91. ;;
  92. *)
  93. printf 'bash'
  94. ;;
  95. esac
  96. }
  97. run_syntax_checks() {
  98. local file
  99. local shell_name
  100. for file in "$@"; do
  101. shell_name="$(syntax_shell_for "$file")"
  102. case "$shell_name" in
  103. sh)
  104. sh -n "$file"
  105. ;;
  106. bash)
  107. bash -n "$file"
  108. ;;
  109. *)
  110. die "unsupported shell for syntax check: $shell_name"
  111. ;;
  112. esac
  113. done
  114. }
  115. format=false
  116. strict=false
  117. all=false
  118. requested_files=()
  119. while [[ $# -gt 0 ]]; do
  120. case "$1" in
  121. --all)
  122. all=true
  123. ;;
  124. --format)
  125. format=true
  126. ;;
  127. --strict)
  128. strict=true
  129. ;;
  130. -h | --help)
  131. usage
  132. exit 0
  133. ;;
  134. --)
  135. shift
  136. requested_files+=("$@")
  137. break
  138. ;;
  139. -*)
  140. die "unknown option: $1"
  141. ;;
  142. *)
  143. requested_files+=("$1")
  144. ;;
  145. esac
  146. shift
  147. done
  148. require_tool shellcheck
  149. if [[ "$format" == true ]]; then
  150. require_tool shfmt
  151. fi
  152. files=()
  153. if [[ "${#requested_files[@]}" -gt 0 ]]; then
  154. collect_requested_shell_files "${requested_files[@]}"
  155. elif [[ "$all" == true ]]; then
  156. collect_all_shell_files
  157. else
  158. collect_changed_shell_files
  159. fi
  160. if [[ "${#files[@]}" -eq 0 ]]; then
  161. echo "No shell files found."
  162. exit 0
  163. fi
  164. if [[ "$format" == true ]]; then
  165. echo "Formatting ${#files[@]} shell files"
  166. shfmt_args=(-i 2 -ci -bn)
  167. shfmt "${shfmt_args[@]}" -w "${files[@]}"
  168. fi
  169. echo "Linting ${#files[@]} shell files"
  170. shellcheck_args=(--severity=warning --external-sources --source-path=SCRIPTDIR)
  171. if [[ "$strict" == true ]]; then
  172. shellcheck_args+=("--enable=check-extra-masked-returns,check-set-e-suppressed,quote-safe-variables,deprecate-which,avoid-nullary-conditions")
  173. fi
  174. shellcheck "${shellcheck_args[@]}" "${files[@]}"
  175. run_syntax_checks "${files[@]}"