#!/bin/bash

#
# This file is distributed under the MIT License. See LICENSE.md for details.
#

set -euo pipefail

function print_usage() {
    cat > /dev/stderr << END_USAGE_TEXT
SYNOPSIS
   $0 [<options>] [FILES...]


DESCRIPTION
   Checks that the files specified in FILES... respect the rev.ng coding
   conventions, and prints all the violations.

   If the FILES... arguments are missing, checks that all the C++ source files in
   the project that have already been added to the git repository project
   respect the rev.ng coding conventions.


OPTIONS
   --help
       Prints this help and exit

   --force-format
       In addition to checking the rev.ng coding conventions on the specified
       files, uses clang-format to try to enforce the coding conventions
       automatically.
       If this fails, exit with failure, otherwise exit with success.
       WARNING: using this option will overwrite your files, make sure to backup
       important stuff.

    --use-local-clang-format-file
      By default, clang-format is executed with a hard-coded configuration that
      should apply to all the projects under the rev.ng umbrella.
      When this option is passed, the hard-coded configuration is ignored, and
      clang-format looks for configuration in a .clang-format file in the
      current directory or in its parent directories.

      This allows to use different clang-format configuration on per-project
      basis, in cases where the hard-coded configuration does not fit (e.g Qt
      projects where Qt coding conventions are a better fit).

      See clang-format documentation for more details.

    --print-clang-format-config
      Print the clang-format configuration to stdout, ignoring all the other
      arguments except for --use-local-clang-format-file.
      The conventions are not checked.

    --commit-range <RANGE>
      Check files changed in the specified commit range

    --HEAD
      Alias for '--commit-range HEAD^..HEAD'

    --cached
      Only check staged files

    --all
      Check all files instead of only changed ones

    FILES
      List of filenames of the files for which you want to check the rev.ng
      coding conventions.

RETURN VALUES
   On success exit code is 0.
   On failure, i.e. if there is at least one file that is not respecting the
   coding conventions, exit code is 1.
END_USAGE_TEXT
}

# git diff wrapper
# Wraps git diff to extract only Added, Modified, Renamed and Copied files
function git_diff_wrapper() {
    local FIELDS RESULT
    readarray -d '' FIELDS < <(git diff -z --diff-filter=AMRC --name-status "$@")
    RESULT=()
    # Simple "parser"
    # The format is
    # A|M <filename>
    # R|C <old_filename> <new_filename>
    set -- "${FIELDS[@]}"
    while [[ $# -gt 0 ]]; do
        type="$1"
        case $type in
            A|M)
                RESULT+=("$2")
                shift; shift;
                ;;
            R|C)
                RESULT+=("$3")
                shift; shift; shift;
                ;;
            *)
                exit 1
                ;;
        esac
    done
    printf '%s\n' "${RESULT[@]}"
}

# git ls-files wrapper
# wrap git ls-files to exclude deleted files
function git_ls_files_wrapper() {
    comm -23 <(git ls-files "$@" | sort) <(git ls-files --deleted | sort)
}


SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

FORCE_FORMAT="0"

USE_CLANG_FORMAT_FILE="0"

PRINT_CLANG_FORMAT_CONFIG="0"

GIT_LS_FILES="git_ls_files_wrapper -m --exclude-standard"

while [[ $# -gt 0 ]]; do
    key="$1"
    case $key in
        --print-clang-format-config)
            PRINT_CLANG_FORMAT_CONFIG="1"
            shift # past argument
            ;;
        --force-format)
            FORCE_FORMAT="1"
            shift # past argument
            ;;
        --use-local-clang-format-file)
            USE_CLANG_FORMAT_FILE="1"
            shift # past argument
            ;;
        --commit-range)
            shift # get next argument
            GIT_LS_FILES="git_diff_wrapper $1"
            shift # past argument
            ;;
        --HEAD)
            GIT_LS_FILES="git_diff_wrapper HEAD^..HEAD"
            shift # past argument
            ;;
        --cached)
            GIT_LS_FILES="git_diff_wrapper --cached"
            shift # past argument
            ;;
        --all)
            GIT_LS_FILES="git_ls_files_wrapper"
            shift # past argument
            ;;
        --help)
            print_usage
            exit 0
            ;;
        -*)
            echo "Error: unrecognized option $key" > /dev/stderr
            print_usage
            exit 1
            ;;
        *)
            break
            ;;
    esac
done

# Retrieve files by extension or shebang
# $1: extension to match
# $2: shebang to match
function find_by_ext_or_shebang() {
    local EXT_FILES SHEBANG_FILES FILES FILE
    readarray -t EXT_FILES < <(grep "\.${1}$" <<< "$ALL_FILES_N")
    SHEBANG_FILES=()
    for FILE in "${ALL_FILES[@]}"; do
        if head -n1 "$FILE" | grep -qE "^#!.*${2}"; then
            SHEBANG_FILES+=("$FILE")
        fi
    done
    FILES=("${EXT_FILES[@]}" "${SHEBANG_FILES[@]}")
    if [[ ${#FILES[@]} -eq 0 ]]; then
        return
    fi
    printf "%s\n" "${FILES[@]}" | sort -u
}

# cat, but print header if input is not empty
# stdin: lines to print
# $1: Header to print if stdin is not empty
function cat_header() {
    local INPUT
    INPUT="$(cat -)"

    if echo "$INPUT" | grep -vqz '^\s*$'; then
        echo "$1"

        # shellcheck disable=SC2001
        sed 's;^;  ;' <<< "$INPUT"
    fi
}


# Checks that all file names are proper
function check_file_names() {
    local FILENAME FILE
    readarray -d '' FILENAME < <(git ls-files -z)
    for FILE in "${FILENAME[@]}"; do
        if grep -q '\s' <<< "$FILE"; then
            echo "ERROR, file '$FILE' has whitespace in filename/path, bailing"
            exit 1
        fi
    done
}

# Get config file path, if not found error out
# $1: config file to search
function config_file_path() {
    if [[ -e "$SCRIPT_PATH/$1" ]]; then
        echo "$SCRIPT_PATH/$1"
        return 0
    elif [[ -e "$SCRIPT_PATH/../../share/revng/$1" ]]; then
        echo "$SCRIPT_PATH/../../share/revng/$1"
        return 0
    else
        echo "Can't find $1" > /dev/stderr
        exit 1
    fi
}

CLANG_FORMAT_STYLE_FILE="$(config_file_path clang-format-style-file)"
CLANG_FORMAT_STYLE=$(cat "$CLANG_FORMAT_STYLE_FILE")

if [[ "$USE_CLANG_FORMAT_FILE" -ne 0 ]]; then
    CLANG_FORMAT_STYLE="file"
fi

# If the user passed the --print-clang-format-config, dump the config and exit
# with success.
if [[ "$PRINT_CLANG_FORMAT_CONFIG" -ne 0 ]]; then
    clang-format --dry-run -style="$CLANG_FORMAT_STYLE" --dump-config
    exit 0
fi


check_file_names

if [[ $# -eq 0 ]]; then
    readarray -t ALL_FILES < <($GIT_LS_FILES)
    if [[ "${#ALL_FILES[@]}" -eq 0 ]]; then exit 0; fi
else
    ALL_FILES=("$@")
fi

ALL_FILES_N=$(printf '%s\n' "${ALL_FILES[@]}")

readarray -t FILES < <(grep -E '\.(c|cc|cpp|h|hpp)$' <<< "$ALL_FILES_N")
FILES_N=$(printf '%s\n' "${FILES[@]}")

readarray -t PYTHON_FILES < <(find_by_ext_or_shebang py python)
readarray -t BASH_FILES < <(find_by_ext_or_shebang sh bash)
readarray -t CMAKE_FILES < <(grep -i cmake <<< "$ALL_FILES_N")


# Run clang-format on FILES
function run_clang_format() {
    if [[ ${#FILES[@]} -eq 0 ]]; then return; fi
    if [[ "$FORCE_FORMAT" -ne 0 ]]; then
        clang-format -style="$CLANG_FORMAT_STYLE" -i "${FILES[@]}" || true
    else
        clang-format --dry-run -style="$CLANG_FORMAT_STYLE" -i "${FILES[@]}"
    fi
}

# Run black
function run_black() {
    if [[ ${#PYTHON_FILES[@]} -eq 0 ]]; then return; fi
    if [[ "$FORCE_FORMAT" -ne 0 ]]; then
        black -q -l 100 "${PYTHON_FILES[@]}"
    else
        black -q --check -l 100 "${PYTHON_FILES[@]}"
    fi
}

# Run flake8
function run_flake8() {
    if [[ ${#PYTHON_FILES[@]} -eq 0 ]]; then return; fi
    flake8 --config "$(config_file_path flake8-config)" "${PYTHON_FILES[@]}"
}

# Run mypy
function run_mypy() {
    local MYPY_FILES
    # mypy will complain that there are multiple __main__ files
    # in the python/scripts directory, manually exclude them
    # Will be fixed by https://github.com/python/mypy/pull/12543/files
    readarray -t MYPY_FILES < <(printf '%s\n' "${PYTHON_FILES[@]}" | grep -vF 'python/scripts')
    if [[ ${#MYPY_FILES[@]} -eq 0 ]]; then return; fi
    mypy --scripts-are-modules --ignore-missing-imports --no-error-summary \
         "${MYPY_FILES[@]}"
}

# Run isort
function run_isort() {
    local CFG
    if [[ ${#PYTHON_FILES[@]} -eq 0 ]]; then return; fi
    CFG="$(config_file_path isort.cfg)"
    if [[ "$FORCE_FORMAT" -ne 0 ]]; then
        isort --settings-file "$CFG" "${PYTHON_FILES[@]}"
    else
        isort --settings-file "$CFG" --diff "${PYTHON_FILES[@]}"
    fi
}


# Run cmake-format
function run_cmake_format() {
    if [[ ${#CMAKE_FILES[@]} -eq 0 ]]; then return; fi
    if [[ "$FORCE_FORMAT" -ne 0 ]]; then
        cmake-format -i "${CMAKE_FILES[@]}" -l error
    else
        cmake-format --check "${CMAKE_FILES[@]}" -l error
    fi
}

# Run shellcheck + check for set -euo pipefail
function run_bash_check() {
    local NO_SET_FILES FILE
    if [[ ${#BASH_FILES[@]} -eq 0 ]]; then return; fi
    shellcheck "${BASH_FILES[@]}"

    NO_SET_FILES=()
    for FILE in "${BASH_FILES[@]}"; do
        if ! head -n 10 "$FILE" | grep -qFe 'set -euo pipefail'; then
            NO_SET_FILES+=("$FILE")
        fi
    done
    if [[ ${#NO_SET_FILES[@]} -gt 0 ]]; then
        echo "There are script files without 'set -euo pipefail':"
        printf '  %s\n' "${NO_SET_FILES[@]}"
    fi
}

# Check that all files have a license header
function run_license_check() {
    local LICENSE_FILES NO_LICENSE_FILES FILE
    readarray -t LICENSE_FILES < <(grep -Ev \
            -e '\.txt$' \
            -e '\.md$' \
            -e '\.rst$' \
            -e '.gitignore' \
            -e 'LICENSE.*' \
            -e 'pyproject.toml' \
            -e 'clang-format-style-file' \
            -e '.clang-tidy' \
            -e '\.dot$' \
            -e 'Doxyfile.in' \
            <<< "$ALL_FILES_N"
    )

    NO_LICENSE_FILES=()
    for FILE in "${LICENSE_FILES[@]}"; do
        if ! head -n 10 "$FILE" | grep -qF 'See LICENSE.md for details'; then
            NO_LICENSE_FILES+=("$FILE")
        fi
    done

    if [[ ${#NO_LICENSE_FILES[@]} -gt 0 ]]; then
        echo "There are files without a license header:"
        printf '  %s\n' "${NO_LICENSE_FILES[@]}"
    fi
}


# Run revng-specific checks on files
function run_revng_checks() {
    local FILTERED_FILES CXX_FILES REGEXPS REGEXP FILE
    if [[ ${#FILES[@]} -eq 0 ]]; then return; fi
    GREP="grep -Hn --color=always"

    # Check for lines longer than 80 columns
    $GREP -E '^.{81,}$' "${FILES[@]}" | cat_header "There are lines longer than 80 characters:"

    # Things should never match
    for REGEXP in '\(--> 0\)' ';;' '^\s*->.*;$' 'Twine [^&]'; do
        $GREP "$REGEXP" "${FILES[@]}"
    done | cat_header "Found snippets that should not be present:"

    # Things should never match (except in support.c)
    readarray -t FILTERED_FILES < <(grep -v \
        -e '^runtime/support\.c$' \
        -e '^lib/Support/Assert\.cpp$' \
        <<< "$FILES_N")

    for REGEXP in '\babort(' '\bassert(' 'assert(false' 'llvm_unreachable'; do
        $GREP "$REGEXP" "${FILTERED_FILES[@]}"
    done | cat_header "Use revng_{assert,check,abort,unreachable}:"

    # Things should never be at the end of a line
    for REGEXP in '::' '<' 'RegisterPass.*>' '} else' '\bopt\b.*>'; do
        $GREP "$REGEXP\$" "${FILES[@]}"
    done | cat_header "Found snippets that shouldn't be at the end of a line:"

    # Includes should never use <..> except for C++ standard includes
    readarray -t CXX_FILES < <(grep -v \
            -e '^runtime/support\.h$' \
            -e '^include/revng/Runtime/.*$' \
            -e '^include/revng/PipelineC/.*$' \
            -e '^include/revng/Support/Assert\.h$' \
            -e '^include/revng/Support/ClassSentinel\.h$' \
            -e '\.c$' \
            <<< "$FILES_N"
    )

    if [[ ${#CXX_FILES[@]} -ne 0 ]]; then
        cat <($GREP "^\s*#include <.*\.hpp>" "${FILES[@]}") \
            <($GREP "^\s*#include <.*\.h>" "${CXX_FILES[@]}") \
            | cat_header "Includes should never user <..> except for C++ standard includes:"
    else
        $GREP "^\s*#include <.*\.hpp>" "${FILES[@]}" | \
            cat_header "Includes should never user <..> except for C++ standard includes:"
    fi

    # Parenthesis at the end of line (except for raw strings)
    $GREP "(\$" "${FILES[@]}" | grep -v 'R"LLVM.*(' | cat_header "Parenthesis at the end of line:"

    # Ban whitespaces at the end of a line
    $GREP " \$" "${FILES[@]}" | cat_header "Whitespace at the end of line:"

    # Ban tabs altogether
    $GREP -P "\t" "${FILES[@]}" | cat_header "Tabs present:"

    # Ensure all files end with a newline
    for FILE in "${ALL_FILES[@]}"; do
        if [[ -s "$FILE" && ! $(tail -c 1 "$FILE" | base64) == "Cg==" ]]; then
            echo "$FILE does not end with a newline"
        fi
    done | cat_header "Files that don't end in a newline:"

    # Things should never be at the beginning of a line
    REGEXPS=('\*>' '/[^/\*]' ':[^:\(]*)' '==' '\!=' '<[^<]' '>' '>=' '<=' '//\s*WIP' '#if\s*[01]')
    for REGEXP in "${REGEXPS[@]}"; do
        $GREP "^\s*$REGEXP" "${FILES[@]}"
    done | cat_header "Found snippets that should never be at the beginning of a line:"

    # Check there are no static functions in C++ header files
    for FILE in "${CXX_FILES[@]}"; do
        if [[ $FILE == *h ]]; then
            $GREP -H '^static\b[^=]*$' "$FILE" | cat
            head -n1 "$FILE" |  grep -E '^#pragma once$' > /dev/null || \
                echo "$FILE: header does not start with #pragma once"
        fi
    done

}

# Replace fd #1 (stdout) with process substitution so that after the exec any
# write to it will also get written into $TMP_FILE
TMP_FILE=$(mktemp)
exec 3>&1 1> >(tee "$TMP_FILE" > /dev/stdout)

function cleanup() {
    trap - SIGINT SIGTERM ERR EXIT
    rm "$TMP_FILE"
}
trap cleanup SIGINT SIGTERM ERR EXIT

EXIT_CODE=0

run_clang_format || EXIT_CODE=$?
run_cmake_format || EXIT_CODE=$?
run_black || EXIT_CODE=$?
run_isort || EXIT_CODE=$?
run_flake8 || EXIT_CODE=$?
run_mypy || EXIT_CODE=$?
run_bash_check || EXIT_CODE=$?
run_license_check || EXIT_CODE=$?
run_revng_checks || EXIT_CODE=$?

if [[ $(wc -c < "$TMP_FILE") -ne 0 || $EXIT_CODE -ne 0 ]]; then
    exit 1
fi
