diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3048387..3e87ebf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -98,3 +98,14 @@ repos: entry: nixfmt language: system types: [nix] + + # Doxygen code coverage + - repo: local + hooks: + - id: doxygen-coverage + name: Doxygen code coverage + language: system + entry: tools/doxy-coverage.py + args: [docs/doxygen/xml, --no-error, --summary-only] + types_or: [c, c++, header] + verbose: true diff --git a/Doxyfile b/Doxyfile index d6d5211..b875352 100644 --- a/Doxyfile +++ b/Doxyfile @@ -17,6 +17,7 @@ USE_MDFILE_AS_MAINPAGE = README.md # Documentation settings GENERATE_LATEX = NO GENERATE_HTML = YES +GENERATE_XML=YES # doxygen-awesome-css settings HTML_EXTRA_STYLESHEET = docs/external/doxygen-awesome-css/doxygen-awesome.css \ diff --git a/tools/doxy-coverage.py b/tools/doxy-coverage.py new file mode 100755 index 0000000..92cae26 --- /dev/null +++ b/tools/doxy-coverage.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python + +# -*- mode: python; coding: utf-8 -*- + +# Based on https://github.com/davatorium/doxy-coverage +# which was forked from https://github.com/alobbs/doxy-coverage +# and modified for use in this project. + +# All files in doxy-coverage are Copyright 2014 Alvaro Lopez Ortega. +# +# Authors: +# * Alvaro Lopez Ortega +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided +# with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +__author__ = "Alvaro Lopez Ortega" +__email__ = "alvaro@alobbs.com" +__copyright__ = "Copyright (C) 2014 Alvaro Lopez Ortega" + +from filecmp import cmp +import os +import sys +import argparse +import xml.etree.ElementTree as ET + +# Defaults +ACCEPTABLE_COVERAGE = 80 + +# Global +ns = None + + +def ERROR(*objs): + print("ERROR: ", *objs, end="\n", file=sys.stderr) + + +def FATAL(*objs): + ERROR(*objs) + sys.exit(0 if ns.no_error else 1) + + +def parse_file(fullpath): + tree = ET.parse(fullpath) + + sourcefile = None + definitions = {} + + for definition in tree.findall("./compounddef//memberdef"): + # Should it be documented + if definition.get("kind") == "function" and definition.get("static") == "yes": + continue + + # Is the definition documented? + documented = False + for k in ("briefdescription", "detaileddescription", "inbodydescription"): + if definition.findall(f"./{k}/"): + documented = True + break + + # Name + d_def = definition.find("./definition") + d_nam = definition.find("./name") + + if not sourcefile: + l = definition.find("./location") + if l is not None: + sourcefile = l.get("file") + + if d_def is not None: + name = d_def.text + elif d_nam is not None: + name = d_nam.text + else: + name = definition.get("id") + + # Aggregate + definitions[name] = documented + + if not sourcefile: + sourcefile = fullpath + + return (sourcefile, definitions) + + +def parse(path): + index_fp = os.path.join(path, "index.xml") + if not os.path.exists(index_fp): + FATAL("Documentation not present. Exiting.", index_fp) + + tree = ET.parse(index_fp) + + files = {} + for entry in tree.findall("compound"): + if entry.get("kind") == "dir": + continue + + file_fp = os.path.join(path, f"{entry.get('refid')}.xml") + sourcefile, definitions = parse_file(file_fp) + + if definitions != {}: + files[sourcefile] = definitions + + return files + + +def report(files, include_files, summary_only): + files_sorted = sorted(files.keys()) + + if len(include_files) != 0: + files_sorted = list(filter(lambda f: f in include_files, files_sorted)) + + if len(files_sorted) == 0: + FATAL("No files to report on. Exiting.") + + files_sorted.reverse() + + total_yes = 0 + total_no = 0 + + for f in files_sorted: + defs = files[f] + + doc_yes = len([d for d in defs.values() if d]) + doc_no = len([d for d in defs.values() if not d]) + doc_per = doc_yes * 100.0 / (doc_yes + doc_no) + + total_yes += doc_yes + total_no += doc_no + + if not summary_only: + print(f"{int(doc_per):3d}% - {f} - ({doc_yes} of {doc_yes + doc_no})") + + if None in defs: + del defs[None] + if not summary_only: + defs_sorted = sorted(defs.keys()) + for d in defs_sorted: + if not defs[d]: + print("\t", d) + + total_all = total_yes + total_no + total_percentage = total_yes * 100 / total_all + print( + f"{"" if summary_only else "\n"}{int(total_percentage)}% API documentation coverage" + ) + return (ns.threshold - total_percentage, 0)[total_percentage > ns.threshold] + + +def main(): + # Arguments + parser = argparse.ArgumentParser() + parser.add_argument( + "dir", action="store", help="Path to Doxygen's XML doc directory" + ) + parser.add_argument( + "include_files", + action="extend", + nargs="*", + help="List of files to check coverage for (Default: all files)", + type=str, + default=[], + ) + parser.add_argument( + "--no-error", + action="store_true", + help="Do not return error code after execution", + ) + parser.add_argument( + "--summary-only", + action="store_true", + help="Only print the summary of the coverage report, without listing the coverage of each file", + ) + parser.add_argument( + "--threshold", + action="store", + help=f"Min acceptable coverage percentage (Default: {ACCEPTABLE_COVERAGE})", + default=ACCEPTABLE_COVERAGE, + type=int, + ) + + global ns + ns = parser.parse_args() + if not ns: + FATAL("ERROR: Couldn't parse parameters") + + # Parse + files = parse(ns.dir) + + # Print report + err = report(files, ns.include_files, ns.summary_only) + if ns.no_error: + return + + sys.exit(round(err)) + + +if __name__ == "__main__": + main()