Files
unison/utils/check-style-clang-format.py

782 lines
23 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# Copyright (c) 2022 Eduardo Nuno Almeida.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation;
#
# This program 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 this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Author: Eduardo Nuno Almeida <enmsa@outlook.pt> [INESC TEC and FEUP, Portugal]
"""
Check and apply the ns-3 coding style recursively to all files in the PATH arguments.
The coding style is defined with the clang-format tool, whose definitions are in
the ".clang-format" file. This script performs the following checks / fixes:
- Check / fix local #include headers with "ns3/" prefix. Respects clang-format guards.
- Check / apply clang-format. Respects clang-format guards.
- Check / trim trailing whitespace. Always checked.
- Check / replace tabs with spaces. Respects clang-format guards.
This script can be applied to all text files in a given path or to individual files.
NOTE: The formatting check requires clang-format (version >= 14) to be found on the path.
The remaining checks do not depend on clang-format and can be executed by disabling clang-format
checking with the "--no-formatting" option.
"""
import argparse
import concurrent.futures
import itertools
import os
import re
import shutil
import subprocess
import sys
from typing import Callable, Dict, List, Tuple
###########################################################
# PARAMETERS
###########################################################
CLANG_FORMAT_VERSIONS = [
17,
16,
15,
14,
]
2023-11-19 20:07:19 -03:00
CLANG_FORMAT_GUARD_ON = "// clang-format on"
CLANG_FORMAT_GUARD_OFF = "// clang-format off"
DIRECTORIES_TO_SKIP = [
2023-11-19 20:07:19 -03:00
"__pycache__",
".git",
".venv",
2023-11-19 20:07:19 -03:00
"bindings",
"build",
"cmake-cache",
"testpy-output",
"venv",
]
# List of files entirely copied from elsewhere that should not be checked,
# in order to optimize the performance of this script
FILES_TO_SKIP = [
2023-11-19 20:07:19 -03:00
"valgrind.h",
]
FILE_EXTENSIONS_TO_CHECK_FORMATTING = [
2023-11-19 20:07:19 -03:00
".c",
".cc",
".h",
]
FILE_EXTENSIONS_TO_CHECK_INCLUDE_PREFIXES = FILE_EXTENSIONS_TO_CHECK_FORMATTING
FILE_EXTENSIONS_TO_CHECK_TABS = [
2023-11-19 20:07:19 -03:00
".c",
".cc",
".cmake",
".css",
".h",
".html",
".js",
".json",
".m",
".md",
".pl",
".py",
".rst",
".sh",
".toml",
".yml",
]
FILES_TO_CHECK_TABS = [
".clang-format",
".clang-tidy",
".codespellrc",
"CMakeLists.txt",
"codespell-ignored-lines",
"codespell-ignored-words",
"ns3",
]
FILE_EXTENSIONS_TO_CHECK_WHITESPACE = FILE_EXTENSIONS_TO_CHECK_TABS + [
".click",
".cfg",
".conf",
".dot",
".gnuplot",
".gp",
2023-11-19 20:07:19 -03:00
".mob",
".ns_params",
".ns_movements",
".params",
".plt",
".seqdiag",
".txt",
]
FILES_TO_CHECK_WHITESPACE = FILES_TO_CHECK_TABS + [
2023-11-19 20:07:19 -03:00
"Makefile",
]
TAB_SIZE = 4
###########################################################
# AUXILIARY FUNCTIONS
###########################################################
def should_analyze_directory(dirpath: str) -> bool:
"""
Check whether a directory should be analyzed.
@param dirpath Directory path.
@return Whether the directory should be analyzed.
"""
_, directory = os.path.split(dirpath)
return directory not in DIRECTORIES_TO_SKIP
2023-11-19 20:07:19 -03:00
def should_analyze_file(
path: str,
files_to_check: List[str],
file_extensions_to_check: List[str],
) -> bool:
"""
Check whether a file should be analyzed.
@param path Path to the file.
@param files_to_check List of files that shall be checked.
@param file_extensions_to_check List of file extensions that shall be checked.
@return Whether the file should be analyzed.
"""
filename = os.path.split(path)[1]
if filename in FILES_TO_SKIP:
return False
extension = os.path.splitext(filename)[1]
return filename in files_to_check or extension in file_extensions_to_check
2023-11-19 20:07:19 -03:00
def find_files_to_check_style(
paths: List[str],
) -> Tuple[List[str], List[str], List[str], List[str]]:
"""
Find all files to be checked in a given list of paths.
@param paths List of paths to the files to check.
@return Tuple [List of files to check include prefixes,
List of files to check formatting,
List of files to check trailing whitespace,
List of files to check tabs].
"""
files_to_check: List[str] = []
for path in paths:
abs_path = os.path.abspath(os.path.expanduser(path))
if os.path.isfile(abs_path):
files_to_check.append(path)
elif os.path.isdir(abs_path):
for dirpath, dirnames, filenames in os.walk(path, topdown=True):
if not should_analyze_directory(dirpath):
# Remove directory and its subdirectories
dirnames[:] = []
continue
files_to_check.extend([os.path.join(dirpath, f) for f in filenames])
else:
2023-11-19 20:07:19 -03:00
raise ValueError(f"Error: {path} is not a file nor a directory")
files_to_check.sort()
files_to_check_include_prefixes: List[str] = []
files_to_check_formatting: List[str] = []
files_to_check_whitespace: List[str] = []
files_to_check_tabs: List[str] = []
for f in files_to_check:
if should_analyze_file(f, [], FILE_EXTENSIONS_TO_CHECK_INCLUDE_PREFIXES):
files_to_check_include_prefixes.append(f)
if should_analyze_file(f, [], FILE_EXTENSIONS_TO_CHECK_FORMATTING):
files_to_check_formatting.append(f)
if should_analyze_file(f, FILES_TO_CHECK_WHITESPACE, FILE_EXTENSIONS_TO_CHECK_WHITESPACE):
files_to_check_whitespace.append(f)
if should_analyze_file(f, FILES_TO_CHECK_TABS, FILE_EXTENSIONS_TO_CHECK_TABS):
files_to_check_tabs.append(f)
return (
files_to_check_include_prefixes,
files_to_check_formatting,
files_to_check_whitespace,
files_to_check_tabs,
)
def find_clang_format_path() -> str:
"""
Find the path to one of the supported versions of clang-format.
If no supported version of clang-format is found, raise an exception.
@return Path to clang-format.
"""
# Find exact version
for version in CLANG_FORMAT_VERSIONS:
2023-11-19 20:07:19 -03:00
clang_format_path = shutil.which(f"clang-format-{version}")
if clang_format_path:
return clang_format_path
# Find default version and check if it is supported
2023-11-19 20:07:19 -03:00
clang_format_path = shutil.which("clang-format")
if clang_format_path:
process = subprocess.run(
2023-11-19 20:07:19 -03:00
[clang_format_path, "--version"],
capture_output=True,
text=True,
check=True,
)
2023-11-19 20:07:19 -03:00
version = process.stdout.strip().split(" ")[-1]
major_version = int(version.split(".")[0])
if major_version in CLANG_FORMAT_VERSIONS:
return clang_format_path
# No supported version of clang-format found
raise RuntimeError(
2023-11-19 20:07:19 -03:00
f"Could not find any supported version of clang-format installed on this system. "
f"List of supported versions: {CLANG_FORMAT_VERSIONS}."
)
###########################################################
# CHECK STYLE MAIN FUNCTIONS
###########################################################
2023-11-19 20:07:19 -03:00
def check_style_clang_format(
paths: List[str],
enable_check_include_prefixes: bool,
enable_check_formatting: bool,
enable_check_whitespace: bool,
enable_check_tabs: bool,
fix: bool,
verbose: bool,
n_jobs: int = 1,
) -> bool:
"""
Check / fix the coding style of a list of files.
@param paths List of paths to the files to check.
@param enable_check_include_prefixes Whether to enable checking #include headers from the same module with the "ns3/" prefix.
@param enable_check_formatting Whether to enable checking code formatting.
@param enable_check_whitespace Whether to enable checking trailing whitespace.
@param enable_check_tabs Whether to enable checking tabs.
@param fix Whether to fix (True) or just check (False) the file.
@param verbose Show the lines that are not compliant with the style.
@param n_jobs Number of parallel jobs.
@return Whether all files are compliant with all enabled style checks.
"""
2023-11-19 20:07:19 -03:00
(
files_to_check_include_prefixes,
files_to_check_formatting,
files_to_check_whitespace,
files_to_check_tabs,
) = find_files_to_check_style(paths)
check_include_prefixes_successful = True
check_formatting_successful = True
check_whitespace_successful = True
check_tabs_successful = True
if enable_check_include_prefixes:
check_include_prefixes_successful = check_style_files(
'#include headers from the same module with the "ns3/" prefix',
check_manually_file,
files_to_check_include_prefixes,
fix,
verbose,
n_jobs,
respect_clang_format_guards=True,
check_style_line_function=check_include_prefixes_line,
)
2023-11-19 20:07:19 -03:00
print("")
if enable_check_formatting:
check_formatting_successful = check_style_files(
2023-11-19 20:07:19 -03:00
"bad code formatting",
check_formatting_file,
files_to_check_formatting,
fix,
verbose,
n_jobs,
clang_format_path=find_clang_format_path(),
)
2023-11-19 20:07:19 -03:00
print("")
if enable_check_whitespace:
check_whitespace_successful = check_style_files(
2023-11-19 20:07:19 -03:00
"trailing whitespace",
check_manually_file,
files_to_check_whitespace,
fix,
verbose,
n_jobs,
respect_clang_format_guards=False,
check_style_line_function=check_whitespace_line,
)
2023-11-19 20:07:19 -03:00
print("")
if enable_check_tabs:
check_tabs_successful = check_style_files(
2023-11-19 20:07:19 -03:00
"tabs",
check_manually_file,
files_to_check_tabs,
fix,
verbose,
n_jobs,
respect_clang_format_guards=True,
check_style_line_function=check_tabs_line,
)
2023-11-19 20:07:19 -03:00
return all(
[
check_include_prefixes_successful,
check_formatting_successful,
check_whitespace_successful,
check_tabs_successful,
]
)
def check_style_files(
style_check_str: str,
check_style_file_function: Callable[..., Tuple[str, bool, List[str]]],
filenames: List[str],
fix: bool,
verbose: bool,
n_jobs: int,
**kwargs,
) -> bool:
"""
Check / fix style of a list of files.
@param style_check_str Description of the check to be performed.
@param check_style_file_function Function used to check the file.
@param filename Name of the file to be checked.
@param fix Whether to fix (True) or just check (False) the file (True).
@param verbose Show the lines that are not compliant with the style.
@param n_jobs Number of parallel jobs.
@param kwargs Additional keyword arguments to the check_style_file_function.
@return Whether all files are compliant with the style.
"""
# Check files
non_compliant_files: List[str] = []
files_verbose_infos: Dict[str, List[str]] = {}
with concurrent.futures.ProcessPoolExecutor(n_jobs) as executor:
non_compliant_files_results = executor.map(
check_style_file_function,
filenames,
itertools.repeat(fix),
itertools.repeat(verbose),
*[arg if isinstance(arg, list) else itertools.repeat(arg) for arg in kwargs.values()],
)
2023-11-19 20:07:19 -03:00
for filename, is_file_compliant, verbose_infos in non_compliant_files_results:
if not is_file_compliant:
non_compliant_files.append(filename)
if verbose:
files_verbose_infos[filename] = verbose_infos
# Output results
if not non_compliant_files:
2023-11-19 20:07:19 -03:00
print(f"- No files detected with {style_check_str}")
return True
else:
n_non_compliant_files = len(non_compliant_files)
if fix:
2023-11-19 20:07:19 -03:00
print(f"- Fixed {style_check_str} in the files ({n_non_compliant_files}):")
else:
2023-11-19 20:07:19 -03:00
print(f"- Detected {style_check_str} in the files ({n_non_compliant_files}):")
for f in non_compliant_files:
if verbose:
2023-11-19 20:07:19 -03:00
print(*[f" {l}" for l in files_verbose_infos[f]], sep="\n")
else:
2023-11-19 20:07:19 -03:00
print(f" - {f}")
# If all files were fixed, there are no more non-compliant files
return fix
###########################################################
# CHECK STYLE FUNCTIONS
###########################################################
2023-11-19 20:07:19 -03:00
def check_formatting_file(
filename: str,
fix: bool,
verbose: bool,
clang_format_path: str,
) -> Tuple[str, bool, List[str]]:
"""
Check / fix the coding style of a file with clang-format.
@param filename Name of the file to be checked.
@param fix Whether to fix (True) or just check (False) the style of the file (True).
@param verbose Show the lines that are not compliant with the style.
@param clang_format_path Path to clang-format.
@return Tuple [Filename,
Whether the file is compliant with the style (before the check),
Verbose information].
"""
verbose_infos: List[str] = []
# Check if the file is well formatted
process = subprocess.run(
[
clang_format_path,
filename,
2023-11-19 20:07:19 -03:00
"-style=file",
"--dry-run",
"--Werror",
# Optimization: In non-verbose mode, only one error is needed to check that the file is not compliant
2023-11-19 20:07:19 -03:00
f"--ferror-limit={0 if verbose else 1}",
],
check=False,
capture_output=True,
text=True,
)
2023-11-19 20:07:19 -03:00
is_file_compliant = process.returncode == 0
if verbose:
verbose_infos = process.stderr.splitlines()
# Fix file
if fix and not is_file_compliant:
process = subprocess.run(
[
clang_format_path,
filename,
2023-11-19 20:07:19 -03:00
"-style=file",
"-i",
],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return (filename, is_file_compliant, verbose_infos)
2023-11-19 20:07:19 -03:00
def check_manually_file(
filename: str,
fix: bool,
verbose: bool,
respect_clang_format_guards: bool,
check_style_line_function: Callable[[str, str, int], Tuple[bool, str, List[str]]],
) -> Tuple[str, bool, List[str]]:
"""
Check / fix a file manually using a function to check / fix each line.
@param filename Name of the file to be checked.
@param fix Whether to fix (True) or just check (False) the style of the file (True).
@param verbose Show the lines that are not compliant with the style.
@param respect_clang_format_guards Whether to respect clang-format guards.
@param check_style_line_function Function used to check each line.
@return Tuple [Filename,
Whether the file is compliant with the style (before the check),
Verbose information].
"""
is_file_compliant = True
verbose_infos: List[str] = []
clang_format_enabled = True
2023-11-19 20:07:19 -03:00
with open(filename, "r", encoding="utf-8") as f:
file_lines = f.readlines()
2023-11-19 20:07:19 -03:00
for i, line in enumerate(file_lines):
# Check clang-format guards
if respect_clang_format_guards:
line_stripped = line.strip()
if line_stripped == CLANG_FORMAT_GUARD_ON:
clang_format_enabled = True
elif line_stripped == CLANG_FORMAT_GUARD_OFF:
clang_format_enabled = False
2023-11-19 20:07:19 -03:00
if not clang_format_enabled and line_stripped not in (
CLANG_FORMAT_GUARD_ON,
CLANG_FORMAT_GUARD_OFF,
):
continue
# Check if the line is compliant with the style and fix it
2023-11-19 20:07:19 -03:00
(is_line_compliant, line_fixed, line_verbose_infos) = check_style_line_function(
line, filename, i
)
if not is_line_compliant:
is_file_compliant = False
file_lines[i] = line_fixed
verbose_infos.extend(line_verbose_infos)
# Optimization: If running in non-verbose check mode, only one error is needed to check that the file is not compliant
if not fix and not verbose:
break
# Update file with the fixed lines
if fix and not is_file_compliant:
2023-11-19 20:07:19 -03:00
with open(filename, "w", encoding="utf-8") as f:
f.writelines(file_lines)
return (filename, is_file_compliant, verbose_infos)
2023-11-19 20:07:19 -03:00
def check_include_prefixes_line(
line: str,
filename: str,
line_number: int,
) -> Tuple[bool, str, List[str]]:
"""
Check / fix #include headers from the same module with the "ns3/" prefix in a line.
@param line The line to check.
@param filename Name of the file to be checked.
@param line_number The number of the line checked.
@return Tuple [Whether the line is compliant with the style (before the check),
Fixed line,
Verbose information].
"""
is_line_compliant = True
line_fixed = line
verbose_infos: List[str] = []
# Check if the line is an #include and extract its header file
line_stripped = line.strip()
header_file = re.findall(r'^#include ["<]ns3/(.*\.h)[">]', line_stripped)
if header_file:
# Check if the header file belongs to the same module and remove the "ns3/" prefix
header_file = header_file[0]
parent_path = os.path.split(filename)[0]
if os.path.exists(os.path.join(parent_path, header_file)):
is_line_compliant = False
2023-11-19 20:07:19 -03:00
line_fixed = (
line_stripped.replace(f"ns3/{header_file}", header_file)
.replace("<", '"')
.replace(">", '"')
+ "\n"
)
header_index = len('#include "')
2023-11-19 20:07:19 -03:00
verbose_infos.extend(
[
f'{filename}:{line_number + 1}:{header_index + 1}: error: #include headers from the same module with the "ns3/" prefix detected',
f" {line_stripped}",
f' {"":{header_index}}^',
]
)
return (is_line_compliant, line_fixed, verbose_infos)
2023-11-19 20:07:19 -03:00
def check_whitespace_line(
line: str,
filename: str,
line_number: int,
) -> Tuple[bool, str, List[str]]:
"""
Check / fix whitespace in a line.
@param line The line to check.
@param filename Name of the file to be checked.
@param line_number The number of the line checked.
@return Tuple [Whether the line is compliant with the style (before the check),
Fixed line,
Verbose information].
"""
is_line_compliant = True
2023-11-19 20:07:19 -03:00
line_fixed = line.rstrip() + "\n"
verbose_infos: List[str] = []
if line_fixed != line:
is_line_compliant = False
line_fixed_stripped_expanded = line_fixed.rstrip().expandtabs(TAB_SIZE)
verbose_infos = [
2023-11-19 20:07:19 -03:00
f"{filename}:{line_number + 1}:{len(line_fixed_stripped_expanded) + 1}: error: Trailing whitespace detected",
f" {line_fixed_stripped_expanded}",
f' {"":{len(line_fixed_stripped_expanded)}}^',
]
return (is_line_compliant, line_fixed, verbose_infos)
2023-11-19 20:07:19 -03:00
def check_tabs_line(
line: str,
filename: str,
line_number: int,
) -> Tuple[bool, str, List[str]]:
"""
Check / fix tabs in a line.
@param line The line to check.
@param filename Name of the file to be checked.
@param line_number The number of the line checked.
@return Tuple [Whether the line is compliant with the style (before the check),
Fixed line,
Verbose information].
"""
is_line_compliant = True
line_fixed = line
verbose_infos: List[str] = []
2023-11-19 20:07:19 -03:00
tab_index = line.find("\t")
if tab_index != -1:
is_line_compliant = False
line_fixed = line.expandtabs(TAB_SIZE)
verbose_infos = [
2023-11-19 20:07:19 -03:00
f"{filename}:{line_number + 1}:{tab_index + 1}: error: Tab detected",
f" {line.rstrip()}",
f' {"":{tab_index}}^',
]
return (is_line_compliant, line_fixed, verbose_infos)
###########################################################
# MAIN
###########################################################
2023-11-19 20:07:19 -03:00
if __name__ == "__main__":
parser = argparse.ArgumentParser(
2023-11-19 20:07:19 -03:00
description="Check and apply the ns-3 coding style recursively to all files in the given PATHs. "
"The script checks the formatting of the file with clang-format. "
'Additionally, it checks #include headers from the same module with the "ns3/" prefix, '
2023-11-19 20:07:19 -03:00
"the presence of trailing whitespace and tabs. "
'Formatting, local #include "ns3/" prefixes and tabs checks respect clang-format guards. '
'When used in "check mode" (default), the script checks if all files are well '
2023-11-19 20:07:19 -03:00
"formatted and do not have trailing whitespace nor tabs. "
"If it detects non-formatted files, they will be printed and this process exits with a "
'non-zero code. When used in "fix mode", this script automatically fixes the files.'
)
2023-11-19 20:07:19 -03:00
parser.add_argument(
"paths",
action="store",
type=str,
nargs="+",
help="List of paths to the files to check",
)
2023-11-19 20:07:19 -03:00
parser.add_argument(
"--no-include-prefixes",
action="store_true",
help='Do not check / fix #include headers from the same module with the "ns3/" prefix',
)
2023-11-19 20:07:19 -03:00
parser.add_argument(
"--no-formatting",
action="store_true",
help="Do not check / fix code formatting",
)
2023-11-19 20:07:19 -03:00
parser.add_argument(
"--no-whitespace",
action="store_true",
help="Do not check / fix trailing whitespace",
)
2023-11-19 20:07:19 -03:00
parser.add_argument(
"--no-tabs",
action="store_true",
help="Do not check / fix tabs",
)
2023-11-19 20:07:19 -03:00
parser.add_argument(
"--fix",
action="store_true",
help="Fix coding style issues detected in the files",
)
2023-11-19 20:07:19 -03:00
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Show the lines that are not well-formatted",
)
2023-11-19 20:07:19 -03:00
parser.add_argument(
"-j",
"--jobs",
type=int,
default=max(1, os.cpu_count() - 1),
help="Number of parallel jobs",
)
args = parser.parse_args()
try:
all_checks_successful = check_style_clang_format(
paths=args.paths,
enable_check_include_prefixes=(not args.no_include_prefixes),
enable_check_formatting=(not args.no_formatting),
enable_check_whitespace=(not args.no_whitespace),
enable_check_tabs=(not args.no_tabs),
fix=args.fix,
verbose=args.verbose,
n_jobs=args.jobs,
)
except Exception as e:
print(e)
sys.exit(1)
if not all_checks_successful:
if args.verbose:
print("")
print('NOTE: To fix the files automatically, run this script with the flag "--fix"')
sys.exit(1)