#! /bin/bash # # Copyright 2018 Undo Ltd. # # https://github.com/barisione/clang-format-hooks # Force variable declaration before access. set -u # Make any failure in piped commands be reflected in the exit code. set -o pipefail readonly bash_source="${BASH_SOURCE[0]:-$0}" ################## # Misc functions # ################## function error_exit() { for str in "$@"; do echo -n "$str" >&2 done echo >&2 exit 1 } ######################## # Command line parsing # ######################## function show_help() { if [ -t 1 ] && hash tput 2> /dev/null; then local -r b=$(tput bold) local -r i=$(tput sitm) local -r n=$(tput sgr0) else local -r b= local -r i= local -r n= fi cat << EOF ${b}SYNOPSIS${n} To reformat git diffs: ${i}$bash_source [OPTIONS] [FILES-OR-GIT-DIFF-OPTIONS]${n} To reformat whole files, including unchanged parts: ${i}$bash_source [-f | --whole-file] FILES${n} ${b}DESCRIPTION${n} Reformat C or C++ code to match a specified formatting style. This command can either work on diffs, to reformat only changed parts of the code, or on whole files (if -f or --whole-file is used). ${b}FILES-OR-GIT-DIFF-OPTIONS${n} List of files to consider when applying clang-format to a diff. This is passed to "git diff" as is, so it can also include extra git options or revisions. For example, to apply clang-format on the changes made in the last few revisions you could use: ${i}\$ $bash_source HEAD~3${n} ${b}FILES${n} List of files to completely reformat. ${b}-f, --whole-file${n} Reformat the specified files completely (including parts you didn't change). The fix is printed on stdout by default. Use -i if you want to modify the files on disk. ${b}--staged, --cached${n} Reformat only code which is staged for commit. The fix is printed on stdout by default. Use -i if you want to modify the files on disk. ${b}-i${n} Reformat the code and apply the changes to the files on disk (instead of just printing the fix on stdout). ${b}--apply-to-staged${n} This is like specifying both --staged and -i, but the formatting changes are also staged for commit (so you can just use "git commit" to commit what you planned to, but formatted correctly). ${b}--style STYLE${n} The style to use for reformatting code. If no style is specified, then it's assumed there's a .clang-format file in the current directory or one of its parents. ${b}--help, -h, -?${n} Show this help. EOF } # getopts doesn't support long options. # getopt mangles stuff. # So we parse manually... declare positionals=() declare has_positionals=false declare whole_file=false declare apply_to_staged=false declare staged=false declare in_place=false declare style=file declare ignored=() while [ $# -gt 0 ]; do declare arg="$1" shift # Past option. case "$arg" in -h | -\? | --help ) show_help exit 0 ;; -f | --whole-file ) whole_file=true ;; --apply-to-staged ) apply_to_staged=true ;; --cached | --staged ) staged=true ;; -i ) in_place=true ;; --style=* ) style="${arg//--style=/}" ;; --style ) [ $# -gt 0 ] || \ error_exit "No argument for --style option." style="$1" shift ;; --internal-opt-ignore-regex=* ) ignored+=("${arg//--internal-opt-ignore-regex=/}") ;; --internal-opt-ignore-regex ) ignored+=("${arg//--internal-opt-ignore-regex=/}") [ $# -gt 0 ] || \ error_exit "No argument for --internal-opt-ignore-regex option." ignored+=("$1") shift ;; -- ) # Stop processing further arguments. if [ $# -gt 0 ]; then positionals+=("$@") has_positionals=true fi break ;; -* ) error_exit "Unknown argument: $arg" ;; *) positionals+=("$arg") ;; esac done # Restore positional arguments, access them from "$@". if [ ${#positionals[@]} -gt 0 ]; then set -- "${positionals[@]}" has_positionals=true fi [ -n "$style" ] || \ error_exit "If you use --style you need to specify a valid style." ####################################### # Detection of clang-format & friends # ####################################### # clang-format. declare format="${CLANG_FORMAT:-}" if [ -z "$format" ]; then format=$(type -p clang-format) fi if [ -z "$format" ]; then error_exit \ $'You need to install clang-format.\n' \ $'\n' \ $'On Ubuntu/Debian this is available in the clang-format package or, in\n' \ $'older distro versions, clang-format-VERSION.\n' \ $'On Fedora it\'s available in the clang package.\n' \ $'You can also specify your own path for clang-format by setting the\n' \ $'$CLANG_FORMAT environment variable.' fi # clang-format-diff. if [ "$whole_file" = false ]; then invalid="/dev/null/invalid/path" if [ "${OSTYPE:-}" = "linux-gnu" ]; then readonly sort_version=-V else # On macOS, sort doesn't have -V. readonly sort_version=-n fi declare paths_to_try=() # .deb packages directly from upstream. # We try these first as they are probably newer than the system ones. while read -r f; do paths_to_try+=("$f") done < <(compgen -G "/usr/share/clang/clang-format-*/clang-format-diff.py" | sort "$sort_version" -r) # LLVM official releases (just untarred in /usr/local). while read -r f; do paths_to_try+=("$f") done < <(compgen -G "/usr/local/clang+llvm*/share/clang/clang-format-diff.py" | sort "$sort_version" -r) # Maybe it's in the $PATH already? This is true for Ubuntu and Debian. paths_to_try+=( \ "$(type -p clang-format-diff 2> /dev/null || echo "$invalid")" \ "$(type -p clang-format-diff.py 2> /dev/null || echo "$invalid")" \ ) # Fedora. paths_to_try+=( \ /usr/share/clang/clang-format-diff.py \ ) # Gentoo. while read -r f; do paths_to_try+=("$f") done < <(compgen -G "/usr/lib/llvm/*/share/clang/clang-format-diff.py" | sort -n -r) # Homebrew. while read -r f; do paths_to_try+=("$f") done < <(compgen -G "/usr/local/Cellar/clang-format/*/share/clang/clang-format-diff.py" | sort -n -r) declare format_diff= # Did the user specify a path? if [ -n "${CLANG_FORMAT_DIFF:-}" ]; then format_diff="$CLANG_FORMAT_DIFF" else for path in "${paths_to_try[@]}"; do if [ -e "$path" ]; then # Found! format_diff="$path" if [ ! -x "$format_diff" ]; then format_diff="python $format_diff" fi break fi done fi if [ -z "$format_diff" ]; then error_exit \ $'Cannot find clang-format-diff which should be shipped as part of the same\n' \ $'package where clang-format is.\n' \ $'\n' \ $'Please find out where clang-format-diff is in your distro and report an issue\n' \ $'at https://github.com/barisione/clang-format-hooks/issues with details about\n' \ $'your operating system and setup.\n' \ $'\n' \ $'You can also specify your own path for clang-format-diff by setting the\n' \ $'$CLANG_FORMAT_DIFF environment variable, for instance:\n' \ $'\n' \ $' CLANG_FORMAT_DIFF="python /.../clang-format-diff.py" \\\n' \ $' ' "$bash_source" fi readonly format_diff fi ############################ # Actually run the command # ############################ if [ "$whole_file" = true ]; then [ "$has_positionals" = true ] || \ error_exit "No files to reformat specified." [ "$staged" = false ] || \ error_exit "--staged/--cached only make sense when applying to a diff." read -r -a format_args <<< "$format" format_args+=("-style=file") [ "$in_place" = true ] && format_args+=("-i") "${format_args[@]}" "$@" else # Diff-only. if [ "$apply_to_staged" = true ]; then [ "$staged" = false ] || \ error_exit "You don't need --staged/--cached with --apply-to-staged." [ "$in_place" = false ] || \ error_exit "You don't need -i with --apply-to-staged." staged=true readonly patch_dest=$(mktemp) trap '{ rm -f "$patch_dest"; }' EXIT else readonly patch_dest=/dev/stdout fi declare git_args=(git diff -U0 --no-color) [ "$staged" = true ] && git_args+=("--staged") # $format_diff may contain a command ("python") and the script to excute, so we # need to split it. read -r -a format_diff_args <<< "$format_diff" [ "$in_place" = true ] && format_diff_args+=("-i") # Build the regex for paths to consider or ignore. # We use negative lookahead assertions which preceed the list of allowed patterns # (that is, the extensions we want). exclusions_regex= if [ "${#ignored[@]}" -gt 0 ]; then for pattern in "${ignored[@]}"; do exclusions_regex="$exclusions_regex(?!$pattern)" done fi "${git_args[@]}" "$@" \ | "${format_diff_args[@]}" \ -p1 \ -style="$style" \ -iregex="$exclusions_regex"'.*\.(c|cpp|cxx|cc|h|hpp|m|mm|js|java)' \ > "$patch_dest" \ || exit 1 if [ "$apply_to_staged" = true ]; then if [ ! -s "$patch_dest" ]; then echo "No formatting changes to apply." exit 0 fi patch -p0 < "$patch_dest" || \ error_exit "Cannot apply fix to local files." git apply -p0 --cached < "$patch_dest" || \ error_exit "Cannot apply fix to git staged changes." fi fi