Skip to main content
`Ctrl`+`K`

#

# Source code for doxysphinx.doxygen

    # =====================================================================================
    #  C O P Y R I G H T
    # -------------------------------------------------------------------------------------
    #  Copyright (c) 2023 by Robert Bosch GmbH. All rights reserved.
    #
    #  Author(s):
    #  - Markus Braun, :em engineering methods AG (contracted by Robert Bosch GmbH)
    #  - Celina Adelhardt, :em engineering methods AG (contracted by Robert Bosch GmbH)
    #  - Gergely Meszaros, Stream HPC B.V. (contracted by Advanced Micro Devices Inc.)
    # =====================================================================================
    """The doxygen module contains classes and functions specific to doxygen."""

    import os
    import re
    import shutil
    from dataclasses import dataclass
    from pathlib import Path
    from typing import Any, Dict, List, Tuple, Union, cast

    try:
        import pyjson5
    except ImportError:
        import json5 as pyjson5

    from doxysphinx.utils.pathlib_fix import path_is_relative_to, path_resolve

    ConfigDict = Dict[str, Union[str, List[str]]]

    @dataclass(frozen=True)
    class DoxyOutput:
        """Class to summarize the strings of the console output and error streams."""

        out: str
        err: str

    def read_doxyconfig(doxyfile: Path, doxygen_exe: str, doxygen_cwd: Path) -> ConfigDict:
        """Read doxyconfig and get full doxygen configuration (also with default values).

        Supplement the doxygen configuration file with the default doxygen configuration and return the final
        key value pairs as a dict.

        :param doxyfile: the doxygen configuration file to read
        :param doxygen_exe: in case one wants to execute doxygen from another directory.
        :return: a dict representing all key-value pairs defined in the final configuration
                 (including warnings from the console output). The value can either be a single value or a list.
        """
        output = _compare_configs(doxyfile, doxygen_exe, doxygen_cwd)
        config = _parse_stdout(output.out)
        config["WARNINGS"] = _parse_stderr(output.err)
        return config

    def _compare_configs(doxyfile: Path, doxygen_exe: str, doxygen_cwd: Path) -> DoxyOutput:
        from subprocess import CalledProcessError, run  # nosec: B404

        doxygen = shutil.which(doxygen_exe)
        if doxygen is None:
            return DoxyOutput("", f"Command not found: {doxygen_exe}")

        try:
            default_config = run(  # nosec: B603
                [doxygen_exe, "-s", "-g", "-"], cwd=doxygen_cwd, capture_output=True, check=True
            )
            custom_config = run(  # nosec: B603
                [doxygen_exe, "-x", doxyfile.absolute()], cwd=doxygen_cwd, capture_output=True, check=False
            )

            return DoxyOutput(
                default_config.stdout.decode("utf-8") + custom_config.stdout.decode("utf-8"),
                default_config.stderr.decode("utf-8") + custom_config.stderr.decode("utf-8"),
            )

        except CalledProcessError as err:
            return DoxyOutput("", f"Error: {err}")

    def _parse_stdout(text: str) -> ConfigDict:
        """Remove comment lines and parse the console output via pyparsing to a dictionary.

        :param text: standard output of the console.
        :return: a configuration dictionary with possibility of lists as values.
        """
        from pyparsing import (
            FollowedBy,
            Group,
            LineEnd,
            Literal,
            ParserElement,
            QuotedString,
            Suppress,
            White,
            Word,
            delimited_list,
            printables,
            srange,
        )

        lines: List[str] = text.split(os.linesep)
        pure_text = os.linesep.join([line for line in lines if _is_config_line(line)])

        ParserElement.set_default_whitespace_chars(" \t")
        line_end = White("\r\n") | LineEnd()

        doxy_flag = Word(srange("[A-Z_]")) + FollowedBy("=")
        list_items = delimited_list(
            QuotedString('"') | QuotedString("'") | Word(printables), Group(Literal("\\") + line_end)
        )
        config_pair = doxy_flag + Suppress("=") + list_items
        config = config_pair.search_string(pure_text).asList()

        # format correcting
        for i in range(len(config)):
            if len(config[i]) > 2:
                flag = config[i][0]
                val = config[i][1:]
                config[i] = [flag, val]

        config_dict = {item[0]: item[1] for item in config}

        return config_dict

    def _is_config_line(line: str) -> bool:
        normalized_line = line.strip()

        # false for comments
        if normalized_line.startswith("#"):
            return False

        return True

    def _parse_stderr(text: str) -> List[str]:
        lines = text.split(os.linesep)
        return [line.replace("warning", "Hint") for line in lines if line]

    class DoxygenSettingsValidator:
        """
        Validate doxygen settings for compatibility with doxysphinx.

        Doxysphinx requires some settings to be present/set in a specific way.
        """

        mandatory_settings = {
            "OUTPUT_DIRECTORY": "",
            "GENERATE_TREEVIEW": "NO",
            "DISABLE_INDEX": "NO",
            # "ALIASES": ["rst=\\verbatim embed:rst:leading-asterisk", "endrst=\\endverbatim"],
            "GENERATE_HTML": "YES",
            "CREATE_SUBDIRS": "NO",
        }
        """
        A dictionary containing mandatory settings for the doxygen config.
        The values of OUTPUT_DIRECTORY and GENERATE_TAGFILE will be set after instantiation and validation of the filepaths.
        """

        optional_settings = {
            "SEARCHENGINE": "NO",
            "DOT_IMAGE_FORMAT": "svg",
            "INTERACTIVE_SVG": "YES",
            "GENERATE_TAGFILE": "",
        }
        """A dictionary containing further optional settings for the doxygen config."""

        @staticmethod
        def _normalize_option(key: str, value: Union[str, List[str]]) -> Union[str, List[str]]:
            """Normalize incoming value before comparing to recommended/mandatory setting."""
            if key in ("OUTPUT_DIRECTORY", "GENERATE_TAGFILE"):
                return Path(cast(str, value)).as_posix()

            return value

        @classmethod
        def _normalize_item(cls, kv: Tuple[str, Union[str, List[str]]]) -> Tuple[str, Union[str, List[str]]]:
            return (kv[0], cls._normalize_option(kv[0], kv[1]))

        validation_errors: List[str] = []
        """List of the validation errors including the doxyflag with its used and the correct value."""
        absolute_out: Path
        """Absolute path of the output directory."""
        validation_msg = ""
        """Validation errors merged in one string."""

        def validate(self, config: ConfigDict, sphinx_source_dir: Path, doxygen_cwd: Path) -> bool:
            """Validate the doxygen configuration regarding the output directory, mandatory and optional settings.

            :param config: the imported doxyfile.
            :param sphinx_source_dir: the sphinx directory (necessary for output directory validation).
            :param doxygen_cwd: the directory for doxygen, paths from doxyfile are relative from here
            :return: False, if there is a deviation to the defined mandatory or optional settings.
            """
            if "WARNINGS" in config:
                self.validation_errors.extend(config["WARNINGS"])

            out_dir_validated = self._validate_doxygen_out_dirs(config, sphinx_source_dir, doxygen_cwd)
            recommended_settings_validated = self._validate_doxygen_recommended_settings(config)
            optional_settings_validated = self._validate_doxygen_optional_settings(config)
            if out_dir_validated and recommended_settings_validated and optional_settings_validated:
                self.validation_msg = "All doxygen settings are set correctly."
                return True
            else:
                for error in self.validation_errors:
                    self.validation_msg += error + "\n"
                return False

        def _validate_doxygen_out_dirs(self, config: ConfigDict, sphinx_source_dir: Path, doxygen_cwd: Path) -> bool:
            """
            Validate the output directory given from doxyfile and set the required values in mandatory settings.

            :param out_dir: output directory value in doxyfile.
            :param sphinx_source_dir: sphinx docs source-directory.
            :param doxygen_cwd the directory for doxygen, paths from doxyfile are relative from here
            :return: True if doxygen output directory is located inside the sphinx docs root,
            False if not and doxysphinx should exit.
            """
            out = Path(doxygen_cwd) / str(config["OUTPUT_DIRECTORY"]) / "html"  # config["HTML_OUTPUT"]
            self.absolute_out = path_resolve(out)
            stringified_out = str(out) if out.is_absolute() else f'"{out}" (resolved to "{self.absolute_out}")'

            self.mandatory_settings["OUTPUT_DIRECTORY"] = Path(cast(str, config["OUTPUT_DIRECTORY"])).as_posix()

            if path_is_relative_to(out, sphinx_source_dir):
                self.optional_settings["GENERATE_TAGFILE"] = out.joinpath("tagfile.xml").relative_to(doxygen_cwd).as_posix()
                return True
            else:
                self.optional_settings["GENERATE_TAGFILE"] = "docs/doxygen/demo/html/tagfile.xml"  # default value
                self.validation_errors.append(
                    f'The doxygen OUTPUT_DIR of "{stringified_out}" defined in the doxyfile'
                    f' is not in a sub-path of the sphinx source directory "{sphinx_source_dir}".'
                )
                return False

        def _validate_doxygen_recommended_settings(self, settings: ConfigDict) -> bool:
            imported_settings = settings
            target_settings = self.mandatory_settings
            validation_successful = True
            if all(self._normalize_item(item) in imported_settings.items() for item in target_settings.items()):
                return validation_successful

            contained_settings_target = {
                key: value for key, value in imported_settings.items() if key in target_settings.keys()
            }

            missing_imported_settings = target_settings.keys() - contained_settings_target.keys()
            if missing_imported_settings:
                for key in missing_imported_settings:
                    self.validation_errors.append(
                        (f"Error: Missing value for {key}, but {target_settings[key]} is required.")
                    )
                validation_successful = False

            for key in contained_settings_target.keys():
                contained_setting = self._normalize_option(key, contained_settings_target[key])
                if not contained_setting == target_settings[key]:
                    self.validation_errors.append(
                        (
                            f"Error: Wrong value {contained_settings_target[key]} for {key}, {target_settings[key]} is required."
                        )
                    )
                    validation_successful = False

            return validation_successful

        def _validate_doxygen_optional_settings(self, settings: ConfigDict) -> bool:
            imported_settings = settings
            target_settings = self.optional_settings
            validation_successful = True
            if all(self._normalize_item(item) in imported_settings.items() for item in target_settings.items()):
                return validation_successful

            contained_settings_target = {
                key: value for key, value in imported_settings.items() if key in target_settings.keys()
            }

            missing_imported_settings = target_settings.keys() - contained_settings_target.keys()
            if missing_imported_settings:
                for key in missing_imported_settings:
                    self.validation_errors.append(
                        (f"Hint: Missing value for {key}, but {target_settings[key]} is recommended.")
                    )
                validation_successful = False

            for key in contained_settings_target.keys():
                contained_setting = self._normalize_option(key, contained_settings_target[key])
                if not contained_setting == target_settings[key]:
                    self.validation_errors.append(
                        (
                            f"Hint: Wrong value {contained_settings_target[key]} for {key}, {target_settings[key]} is recommended."
                        )
                    )
                    validation_successful = False

            return validation_successful

    def read_js_data_file(js_data_file: Path) -> Any:
        """
        Read a doxygen javascript data file (e.g. menudata.js) and returns the data as json structure.

        :param js_data_file: The doxygen js data file to use.
        :return: a json like dict of the data.
        """
        data = js_data_file.read_text(encoding="utf-8")
        sanitized = re.sub(r"var .*=", "", data)
        result: Any = pyjson5.loads(sanitized)
        return result

    class DoxygenOutputPathValidator:
        """Validates doxygen html output paths."""

        def __init__(self) -> None:
            """Create an instance of DoxygenOutputPathValidator."""
            self.validation_msg: str = ""

        def validate(self, doxygen_html_output: Path) -> bool:
            """Validate a doxygen html output path.

            This is just meant to catch typos in paths etc. It will just check if a "doxygen.css" file is existing
            In the html output path.

            :param doxygen_html_output: The path where doxygen generates its html file to.
            :return: True if the path is valid else false.
            """
            svg_exists = (doxygen_html_output / "doxygen.css").exists()
            if not svg_exists:
                self.validation_msg = (
                    f'The directory "{doxygen_html_output}" seems to be no valid doxygen html output '
                    "(we're checking for existance of \"doxygen.css\" and weren't able to find it there)."
                )
            return svg_exists