Source code for statick_tool.plugins.tool.clang_format
"""Apply clang-format tool and gather results."""
import argparse
import difflib
import logging
import os
import re
import subprocess
from typing import Match, Optional, Pattern
from statick_tool.issue import Issue
from statick_tool.package import Package
from statick_tool.plugins.tool.clang_format_parser import ClangFormatXMLParser
from statick_tool.tool_plugin import ToolPlugin
[docs]
class ClangFormatToolPlugin(ToolPlugin):
"""Apply clang-format tool and gather results."""
[docs]
def get_name(self) -> str:
"""Get name of tool.
Returns:
Name of the tool.
"""
return "clang-format"
[docs]
def gather_args(self, args: argparse.Namespace) -> None:
"""Gather arguments.
Args:
args: Flags for this plugin will be added to these existing arguments.
"""
args.add_argument(
"--clang-format-bin",
dest="clang_format_bin",
type=str,
help="clang-format binary path",
)
args.add_argument(
"--clang-format-raise-exception",
dest="clang_format_raise_exception",
action="store_true",
help="clang-format raise exception on mismatched " "configuration file",
)
args.add_argument(
"--clang-format-ignore-exception",
dest="clang_format_raise_exception",
action="store_false",
help="clang-format ignore exception on mismatched " "configuration file",
)
args.set_defaults(clang_format_raise_exception=True)
args.add_argument(
"--clang-format-issue-per-line",
dest="clang_format_issue_per_line",
action="store_true",
help="clang-format will report an issue per line of diff instead of per file",
)
[docs]
def get_binary(
self, level: Optional[str] = None, package: Optional[Package] = None
) -> str:
"""Return the name of the tool binary.
Args:
level: The level of the scan.
package: The package to scan.
Returns:
The name of the tool binary.
"""
user_version = None
if level is not None and self.plugin_context:
user_version = self.plugin_context.config.get_tool_config(
self.get_name(), level, "version"
)
binary = self.get_name()
if user_version is not None:
binary = f"{binary}-{user_version}"
# If the user explicitly specifies a binary, let that override the user_version
if (
self.plugin_context
and self.plugin_context.args.clang_format_bin is not None
):
binary = self.plugin_context.args.clang_format_bin
return binary
[docs]
def scan( # pylint: disable=too-many-return-statements, too-many-branches
self, package: Package, level: str
) -> Optional[
list[Issue]
]: # pylint: disable=too-many-locals, too-many-branches, too-many-return-statements
"""Run tool and gather output.
Args:
package: The package to scan.
level: The level of the scan.
Returns:
A list of issues found by the tool.
"""
if "make_targets" not in package and "headers" not in package:
return []
clang_format_bin = self.get_binary(level=level)
files: list[str] = []
if "make_targets" in package:
for target in package["make_targets"]:
files += target["src"]
if "headers" in package:
files += package["headers"]
check: Optional[bool] = self.check_configuration(clang_format_bin)
if check is None:
return None
if not check:
return []
total_output: list[str] = []
try:
for src in files:
output = subprocess.check_output(
[clang_format_bin, src, "-output-replacements-xml"],
stderr=subprocess.STDOUT,
universal_newlines=True,
)
if (
not self.plugin_context
or not self.plugin_context.args.clang_format_issue_per_line
):
output = src + "\n" + output
if (
self.plugin_context
and self.plugin_context.args.clang_format_raise_exception
):
total_output.append(output)
except (IOError, OSError) as ex:
logging.warning("clang-format binary failed: %s", clang_format_bin)
logging.warning("%s exception: %s", self.get_name(), ex.strerror)
if (
self.plugin_context
and self.plugin_context.args.clang_format_raise_exception
):
return None
return []
except subprocess.CalledProcessError as ex:
logging.warning("clang-format binary failed: %s.", clang_format_bin)
logging.warning("Returncode: %d", ex.returncode)
logging.warning("%s exception: %s", self.get_name(), ex.output)
if (
self.plugin_context
and self.plugin_context.args.clang_format_raise_exception
):
return None
return []
for output in total_output:
logging.debug("%s", output)
if self.plugin_context and self.plugin_context.args.output_directory:
with open(self.get_name() + ".log", "w", encoding="utf8") as fid:
for output in total_output:
fid.write(output)
issues: list[Issue] = self.parse_tool_output(total_output, files)
return issues
[docs]
def check_configuration(self, clang_format_bin: str) -> Optional[bool]:
"""Check that configuration is configured properly.
Args:
clang_format_bin: The clang-format binary.
Returns:
True if the configuration is correct, False otherwise.
"""
if self.plugin_context is None:
return False
default_file_name = "_clang-format"
format_file_name = self.plugin_context.resources.get_file(default_file_name)
if not os.path.isfile(os.path.expanduser("~/" + default_file_name)):
default_file_name = ".clang-format"
exc_msg = (
"_clang-format or .clang-format style is not correct. "
f"There is one located in {format_file_name}. "
"Put this file in your home directory."
)
try:
with (
open(
os.path.expanduser("~/" + default_file_name), "r", encoding="utf8"
) as home_format_file,
open(
format_file_name, "r", encoding="utf8" # type: ignore
) as format_file,
):
actual_format = home_format_file.read()
target_format = format_file.read()
diff = difflib.context_diff(
actual_format.splitlines(), target_format.splitlines()
)
for line in diff:
if (
line.startswith("+ ")
or line.startswith("- ")
or line.startswith("! ")
) and len(line) > 2:
if line[2:].strip() and line[2:].strip()[0] != "#":
exc = subprocess.CalledProcessError(
-1, clang_format_bin, exc_msg
)
if self.plugin_context.args.clang_format_raise_exception:
raise exc
except (IOError, OSError) as ex:
logging.warning("%s", exc_msg)
logging.warning("%s exception: %s", self.get_name(), ex.strerror)
if self.plugin_context.args.clang_format_raise_exception:
return None
return False
except subprocess.CalledProcessError as ex:
logging.warning("%s Returncode = %d", exc_msg, ex.returncode)
if self.plugin_context.args.clang_format_raise_exception:
return None
return True
[docs]
def parse_tool_output( # pylint: disable=too-many-locals
self, total_output: list[str], files: list[str]
) -> list[Issue]:
"""Parse tool output and report issues.
Args:
total_output: The output from the tool.
files: The files to scan.
Returns:
A list of issues found by the tool.
"""
clangformat_re = r"<replacement offset="
parse: Pattern[str] = re.compile(clangformat_re)
issues: list[Issue] = []
if (
not self.plugin_context
or not self.plugin_context.args.clang_format_issue_per_line
):
for output in total_output:
lines = output.splitlines()
filename = lines[0]
count = 0
for line in lines:
match: Optional[Match[str]] = parse.match(line)
if match:
count += 1
if count > 0:
issues.append(
Issue(
filename,
0,
self.get_name(),
"format",
1,
str(count) + " replacements",
None,
)
)
else:
parser = ClangFormatXMLParser()
for output, filename in zip(total_output, files):
report = parser.parse_xml_output(output, filename)
for issue in report:
msg: str = (
f"Replace\n{issue['deletion']}\nwith\n{issue['addition']}\n"
)
issues.append(
Issue(
filename,
int(issue["line_no"]),
self.get_name(),
"format",
1,
msg,
None,
)
)
return issues