Source code for statick_tool.plugins.tool.cccc
r"""Apply CCCC tool and gather results.
To run the CCCC tool locally (without Statick) one way to do so is:
find . -name \*.h -print -o -name \*.cpp -print | xargs cccc
That will generate several reports, including HTML. The results can be viewd in a web
browser.
"""
import argparse
import csv
import logging
import subprocess
from pathlib import Path
from typing import Any, Optional
import xmltodict
import yaml
from statick_tool.issue import Issue
from statick_tool.package import Package
from statick_tool.tool_plugin import ToolPlugin
[docs]
class CCCCToolPlugin(ToolPlugin):
"""Apply CCCC tool and gather results."""
[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(
"--cccc-bin", dest="cccc_bin", type=str, help="cccc binary path"
)
args.add_argument(
"--cccc-config", dest="cccc_config", type=str, help="cccc config file"
)
[docs]
def get_binary( # pylint: disable=unused-argument
self, level: Optional[str] = None, package: Optional[Package] = None
) -> str:
"""Get tool binary name.
Args:
level: The level of the scan.
package: The package to scan.
Returns:
The name of the tool binary.
"""
binary = self.get_name()
if (
self.plugin_context is not None
and self.plugin_context.args.cccc_bin is not None
):
binary = self.plugin_context.args.cccc_bin
return binary
[docs]
def get_version(self) -> str:
"""Figure out and return the version of the tool that's installed.
If no version is found the function returns "Uninstalled".
Returns:
The version of the tool.
"""
return self.get_version_from_apt()
[docs]
def scan( # pylint: disable=too-many-branches,too-many-locals
self, package: Package, level: str
) -> Optional[list[Issue]]:
"""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 "c_src" not in package.keys() or not package["c_src"]:
return []
if self.plugin_context is None:
return None
cccc_bin = self.get_binary()
cccc_config = "cccc.opt"
if self.plugin_context.args.cccc_config is not None:
cccc_config = self.plugin_context.args.cccc_config
config_file = self.plugin_context.resources.get_file(cccc_config)
if config_file is not None:
opts = ["--opt_infile=" + config_file]
else:
return []
opts.append(" --lang=c++")
issues: list[Issue] = []
for src in package["c_src"]:
tool_output_dir: str = ".cccc-" + Path(src).name
opts.append("--outdir=" + tool_output_dir)
try:
subproc_args: list[str] = [cccc_bin] + opts + [src]
logging.debug(" ".join(subproc_args))
log_output: bytes = subprocess.check_output(
subproc_args, stderr=subprocess.STDOUT
)
except subprocess.CalledProcessError as ex:
if ex.returncode == 1:
log_output = ex.output
else:
logging.warning("Problem %d", ex.returncode)
logging.warning("%s exception: %s", self.get_name(), ex.output)
return None
except OSError as ex:
logging.warning("Couldn't find cccc executable! (%s)", ex)
return None
logging.debug("%s", log_output)
if self.plugin_context and self.plugin_context.args.output_directory:
with open(self.get_name() + ".log", "ab") as flog:
flog.write(log_output)
try:
with open(tool_output_dir + "/cccc.xml", encoding="utf8") as fresults:
tool_output = xmltodict.parse(
fresults.read(), dict_constructor=dict
)
except FileNotFoundError:
continue
issues.extend(self.parse_tool_output(tool_output, src, config_file))
return issues
[docs]
def parse_tool_output( # pylint: disable=too-many-branches
self, output: dict[Any, Any], src: str, config_file: str
) -> list[Issue]:
"""Parse tool output and report issues.
Args:
output: The output from the tool.
src: The source file being scanned.
config_file: The configuration file.
Returns:
A list of issues found by the tool.
"""
if "CCCC_Project" not in output:
return []
config = self.parse_config(config_file)
logging.debug(config)
results: dict[Any, Any] = {}
logging.debug(yaml.dump(output))
if (
"structural_summary" in output["CCCC_Project"]
and output["CCCC_Project"]["structural_summary"]
and "module" in output["CCCC_Project"]["structural_summary"]
):
for module in output["CCCC_Project"]["structural_summary"]["module"]:
if "name" not in module or isinstance(module, str):
break
metrics: dict[Any, Any] = {}
for field in module:
metrics[field] = {}
if "@value" in module[field]:
metrics[field]["value"] = module[field]["@value"]
if "@level" in module[field]:
metrics[field]["level"] = module[field]["@level"]
results[module["name"]] = metrics
if (
"procedural_summary" in output["CCCC_Project"]
and output["CCCC_Project"]["procedural_summary"]
and "module" in output["CCCC_Project"]["procedural_summary"]
):
for module in output["CCCC_Project"]["procedural_summary"]["module"]:
if "name" not in module or isinstance(module, str):
break
metrics = results[module["name"]]
for field in module:
metrics[field] = {}
if "@value" in module[field]:
metrics[field]["value"] = module[field]["@value"]
if "@level" in module[field]:
metrics[field]["level"] = module[field]["@level"]
results[module["name"]] = metrics
if (
"oo_design" in output["CCCC_Project"]
and output["CCCC_Project"]["oo_design"]
and "module" in output["CCCC_Project"]["oo_design"]
):
for module in output["CCCC_Project"]["oo_design"]["module"]:
if "name" not in module or isinstance(module, str):
break
metrics = results[module["name"]]
for field in module:
metrics[field] = {}
if "@value" in module[field]:
metrics[field]["value"] = module[field]["@value"]
if "@level" in module[field]:
metrics[field]["level"] = module[field]["@level"]
results[module["name"]] = metrics
issues: list[Issue] = self.find_issues(config, results, src)
return issues
[docs]
@classmethod
def parse_config(cls, config_file: str) -> dict[str, str]:
"""Parse CCCC configuration file.
Gets warning and error thresholds for all the metrics. An explanation to dump
default values to a configuration file is at:
http://sarnold.github.io/cccc/CCCC_User_Guide.html#config
`cccc --opt_outfile=cccc.opt`
Args:
config_file: The configuration file.
Returns:
A dictionary containing the parsed configuration.
"""
config: dict[Any, Any] = {}
if config_file is None:
return config
with open(config_file, "r", encoding="utf8") as csvfile:
reader = csv.DictReader(csvfile, delimiter="@")
for row in reader:
if row["CCCC_FileExt"] == "CCCC_MetTmnt":
config[row[".ADA"]] = {
"warn": row["ada.95"],
"error": row[""],
"name": row[None][3],
"key": row[".ADA"],
}
return config
[docs]
def find_issues(
self, config: dict[Any, Any], results: dict[Any, Any], src: str
) -> list[Issue]:
"""Identify issues by comparing tool results with tool configuration.
Args:
config: The configuration dictionary.
results: The results dictionary.
src: The source file being scanned.
Returns:
A list of issues found by the tool.
"""
issues: list[Issue] = []
dummy = []
logging.debug("Results")
logging.debug(results)
for key, val in results.items():
for item in val.keys():
val_id = self.convert_name_to_id(item)
if val_id != "" and val_id in config.keys():
if val[item]["value"] == "------" or val[item]["value"] == "******":
dummy.append("only here for code coverage")
continue
result = float(val[item]["value"])
thresh_error = float(config[val_id]["error"])
thresh_warn = float(config[val_id]["warn"])
msg = key + " - " + config[val_id]["name"]
msg += f" - value: {result}, thresholds warning: {thresh_warn}"
msg += f", error: {thresh_error}"
if ("level" in val[item] and val[item]["level"] == "2") or (
result > thresh_error
):
issues.append(
Issue(
src,
0,
self.get_name(),
"error",
5,
msg,
None,
)
)
elif ("level" in val[item] and val[item]["level"] == "1") or (
result > thresh_warn
):
issues.append(
Issue(
src,
0,
self.get_name(),
"warn",
3,
msg,
None,
)
)
return issues
[docs]
@classmethod
def convert_name_to_id(cls, name: str) -> str: # pylint: disable=too-many-branches
"""Convert result name to configuration name.
The name given in CCCC results is different than the name given in CCCC
configuration. This will map the name in the configuration file to the name
given in the results.
Args:
name: The name to convert.
Returns:
The converted name.
"""
name_id = ""
if name == "IF4":
name_id = "IF4"
elif name == "fan_out_concrete":
name_id = "FOc"
elif name == "IF4_visible":
name_id = "IF4v"
elif name == "coupling_between_objects":
name_id = "CBO"
elif name == "fan_in_visible":
name_id = "FIv"
elif name == "weighted_methods_per_class_unity":
name_id = "WMC1"
elif name == "fan_out":
name_id = "FO"
elif name == "weighted_methods_per_class_visibility":
name_id = "WMCv"
elif name == "fan_out_visible":
name_id = "FOv"
elif name == "IF4_concrete":
name_id = "IF4c"
elif name == "depth_of_inheritance_tree":
name_id = "DIT"
elif name == "number_of_children":
name_id = "NOC"
elif name == "fan_in_concrete":
name_id = "FIc"
elif name == "fan_in":
name_id = "FI"
elif name == "lines_of_comment":
name_id = "COM"
elif name == "lines_of_code_per_line_of_comment":
name_id = "L_C"
elif name == "McCabes_cyclomatic_complexity":
name_id = "MVGper"
elif name == "lines_of_code":
name_id = "LOCp"
elif name == "McCabes_cyclomatic_complexity_per_line_of_comment":
name_id = "M_C"
return name_id