15.1.928. tablet_qt/tools/clang_format_camcops.py

#!/usr/bin/env python

"""
tablet_qt/tools/clang_format_camcops.py

===============================================================================

    Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
    Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

    This file is part of CamCOPS.

    CamCOPS is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CamCOPS is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.

===============================================================================

Run clang-format over all our C++ code.

"""

# =============================================================================
# Imports
# =============================================================================

import argparse
from enum import Enum
import filecmp
import glob
import logging
import os
import shutil
from subprocess import Popen, PIPE
import sys
import tempfile
from typing import List, Set, Tuple

from cardinal_pythonlib.logs import main_only_quicksetup_rootlogger
from rich_argparse import RichHelpFormatter

from camcops_server.cc_modules.cc_baseconstants import (
    EXIT_SUCCESS,
    EXIT_FAILURE,
)

log = logging.getLogger(__name__)


# =============================================================================
# Constants
# =============================================================================

CLANG_FORMAT_EXECUTABLE = "clang-format-14"
DIFFTOOL = "meld"
ENC = sys.getdefaultencoding()


class Command(Enum):
    CHECK = "check"
    DIFF = "diff"
    MODIFY = "modify"
    LIST = "list"
    PRINT = "print"


# =============================================================================
# Directories
# =============================================================================

THIS_DIR = os.path.dirname(os.path.realpath(__file__))
CLANG_FORMAT_STYLE_FILE = os.path.abspath(
    os.path.join(THIS_DIR, "clang_format_camcops.yaml")
)
CAMCOPS_CPP_DIR = os.path.abspath(os.path.join(THIS_DIR, os.pardir))

INCLUDE_GLOBS = [
    f"{CAMCOPS_CPP_DIR}/**/*.cpp",
    f"{CAMCOPS_CPP_DIR}/**/*.h",
]
EXCLUDE_GLOBS = [
    # Code by Qt whose format we won't fiddle with too much.
    f"{CAMCOPS_CPP_DIR}/**/boxlayouthfw.*",
    f"{CAMCOPS_CPP_DIR}/**/qcustomplot.*",
    f"{CAMCOPS_CPP_DIR}/**/qtlayouthelpers.*",
    f"{CAMCOPS_CPP_DIR}/**/sqlcachedresult.*",
    f"{CAMCOPS_CPP_DIR}/**/sqlcipherdriver.*",
    f"{CAMCOPS_CPP_DIR}/**/sqlcipherhelpers.*",
    f"{CAMCOPS_CPP_DIR}/**/sqlcipherresult.*",
]


# =============================================================================
# Apply clang-format to our source code
# =============================================================================


def runit(cmdargs: List[str]) -> Tuple[str, str, int]:
    log.debug(cmdargs)
    p = Popen(cmdargs, stdout=PIPE, stderr=PIPE)
    output, error = p.communicate()
    output = output.decode(ENC)
    error = error.decode(ENC)
    return output, error, p.returncode


def clang_format_camcops_source() -> None:
    """
    Apply clang-format to CamCOPS C++ source code, to standardize code style.
    """
    parser = argparse.ArgumentParser(formatter_class=RichHelpFormatter)
    parser.add_argument(
        "command",
        type=str,
        choices=[x.value for x in Command],
        help=f"Command to execute. "
        f"{Command.CHECK.value!r}: ensure nothing needs modifying; return "
        f"exit code {EXIT_SUCCESS} if everything is OK and {EXIT_FAILURE} if "
        f"something needs fixing. "
        f"{Command.DIFF.value!r}: launch a diff for the first file specified. "
        f"{Command.LIST.value!r}: list files only. "
        f"{Command.MODIFY.value!r}: modify all files in place. "
        f"{Command.PRINT.value!r}: print all results to stdout.",
    )
    parser.add_argument(
        "files",
        type=str,
        nargs="*",
        help="Files to modify (leave blank for all).",
    )
    parser.add_argument("--verbose", action="store_true", help="Be verbose")
    parser.add_argument(
        "--clangformat",
        type=str,
        default=shutil.which(CLANG_FORMAT_EXECUTABLE),
        help=f"Path to clang-format. Priority: (1) this argument, (2) the "
        f"results of 'which {CLANG_FORMAT_EXECUTABLE}'.",
    )
    parser.add_argument(
        "--diffall",
        action="store_true",
        help=f"For {Command.DIFF.value!r}: proceed to diff all files, not "
        f"just the first",
    )
    parser.add_argument(
        "--diffskipidentical",
        action="store_true",
        help=f"For {Command.DIFF.value!r}: skip files that are identical "
        f"after reformatting",
    )
    parser.add_argument(
        "--difftool",
        type=str,
        default=shutil.which(DIFFTOOL),
        help=f"Tool to use for diff. Priority: (1) this argument, (2) the "
        f"results of 'which {DIFFTOOL}'",
    )
    args = parser.parse_args()
    command = Command(args.command)

    main_only_quicksetup_rootlogger(
        level=logging.DEBUG if args.verbose else logging.INFO
    )

    # -------------------------------------------------------------------------
    # Files
    # -------------------------------------------------------------------------

    # Files to process:
    if args.files:
        cpp_files = args.files
    else:
        cpp_files = set()  # type: Set[str]
        for inc in INCLUDE_GLOBS:
            cpp_files = cpp_files.union(glob.glob(inc))
        for exc in EXCLUDE_GLOBS:
            cpp_files = cpp_files.difference(glob.glob(exc))
        cpp_files = sorted(cpp_files)  # type: List[str]

    # -------------------------------------------------------------------------
    # Build clazy command
    # -------------------------------------------------------------------------
    # Basic arguments
    common_clangformat_args = [
        args.clangformat,  # executable
        "--Werror",  # warnings as errors
        f"-style=file:{CLANG_FORMAT_STYLE_FILE}",  # style control file
    ]
    if command == Command.MODIFY:
        common_clangformat_args.append("-i")  # edit in place
    if args.verbose:
        common_clangformat_args.append("--verbose")  # be verbose

    # -------------------------------------------------------------------------
    # Run it
    # -------------------------------------------------------------------------
    success = True
    for filename in cpp_files:
        filename = os.path.abspath(filename)
        if command == Command.CHECK:
            log.info(f"Checking: {filename}")
        elif command == Command.DIFF:
            log.info(f"Diff: {filename}")
        elif command == Command.LIST:
            print(filename)
            continue
        elif command == Command.MODIFY:
            log.info(f"Modifying: {filename}")
        else:
            log.info(f"Printing: {filename}")
        # If we are in modification mode, the next command does the
        # modification directly:
        clangformatcmd = common_clangformat_args + [filename]
        output, error, retcode = runit(clangformatcmd)
        if retcode:
            raise RuntimeError("clang-format error: \n" + error)
        if command in (Command.CHECK, Command.DIFF):
            with tempfile.TemporaryDirectory() as tempdir:
                newfilename = os.path.join(
                    tempdir, os.path.basename(filename) + ".altered"
                )
                with open(newfilename, "wt") as f:
                    f.write(output)
                if command == Command.DIFF:
                    # Diff
                    if args.diffskipidentical and filecmp.cmp(
                        filename, newfilename
                    ):
                        log.info(f"Skipping unmodified file: {filename}")
                        continue
                    diffcmd = [args.difftool, filename, newfilename]
                    runit(diffcmd)
                    if not args.diffall:
                        log.info("Stopping after first diff")
                        break  # no more files
                else:
                    # Check
                    if not filecmp.cmp(filename, newfilename):
                        # Files differ
                        log.warning(f"File would be modified: {filename}")
                        success = False

        elif command == Command.PRINT:
            # Print
            print(output)

    sys.exit(EXIT_SUCCESS if success else EXIT_FAILURE)


# =============================================================================
# Command-line entry point
# =============================================================================

if __name__ == "__main__":
    clang_format_camcops_source()