# Distributed under the OSI-approved BSD 3-Clause License.  See accompanying
# file LICENSE.rst or https://cmake.org/licensing for details.

#[=======================================================================[.rst:
CTestCoverageCollectGCOV
------------------------

.. versionadded:: 3.2

This module is intended for use in CTest dashboard scripts and provides a
command to generate a tarball containing code coverage reports.

Load this module in a CTest script with:

.. code-block:: cmake

  include(CTestCoverageCollectGCOV)

Commands
^^^^^^^^

This module provides the following command:

.. command:: ctest_coverage_collect_gcov

  Runs ``gcov`` and packages a tar file for CDash:

  .. code-block:: cmake

    ctest_coverage_collect_gcov(
      TARBALL <tar-file>
      [TARBALL_COMPRESSION <compression>]
      [SOURCE <source-dir>]
      [BUILD <build-dir>]
      [GCOV_COMMAND <gcov-command>]
      [GCOV_OPTIONS <options>...]
      [GLOB]
      [DELETE]
      [QUIET]
    )

  This command runs ``gcov`` on all ``.gcda`` files found in the binary tree
  and packages the resulting ``.gcov`` files into a tar file, along with the
  following:

  * *data.json* file that defines the source and build directories for use
    by CDash.
  * *Labels.json* files that indicate any :prop_sf:`LABELS` that have been
    set on the source files.
  * The *uncovered* directory containing any uncovered files found by
    :variable:`CTEST_EXTRA_COVERAGE_GLOB`.

  The resulting tar file can be submitted to CDash for display using the
  :command:`ctest_submit(CDASH_UPLOAD)` command.

  The arguments are:

  ``TARBALL <tar-file>``
    Specify the location of the ``.tar`` file to be created for later
    upload to CDash.  Relative paths will be interpreted with respect
    to the top-level build directory.

  ``TARBALL_COMPRESSION <compression>``
    .. versionadded:: 3.18

    Specify a compression algorithm for the
    ``TARBALL`` data file.  Using this option reduces the size of the data file
    before it is submitted to CDash.
    ``<compression>`` should be one of the following:

    * ``GZIP``
    * ``BZIP2``
    * ``LZMA``

      .. versionadded:: 4.3

    * ``LZMA2``

      .. versionadded:: 4.3

      This is an alias for ``XZ``.

    * ``XZ``
    * ``ZSTD``
    * ``FROM_EXT``
    * An expression that CMake evaluates as ``FALSE``

    The default value is ``BZIP2``.

    If ``FROM_EXT`` is specified, the resulting file will be compressed based on
    the file extension of the ``<tar-file>`` (i.e. ``.tar.gz`` will use ``GZIP``
    compression). File extensions that will produce compressed output include:

    * ``.tar.gz``
    * ``.tgz``
    * ``.tar.bzip2``
    * ``.tbz``
    * ``.tar.xz``
    * ``.txz``
    * ``.tar.lzma``

      .. versionadded:: 4.3

    * ``.tlzma``

      .. versionadded:: 4.3

    * ``.tar.zst``

      .. versionadded:: 4.3

    * ``.tzst``

      .. versionadded:: 4.3

  ``SOURCE <source-dir>``
    Specify the top-level source directory for the build.
    Default is the value of :variable:`CTEST_SOURCE_DIRECTORY`.

  ``BUILD <build-dir>``
    Specify the top-level build directory for the build.
    Default is the value of :variable:`CTEST_BINARY_DIRECTORY`.

  ``GCOV_COMMAND <gcov-command>``
    Specify the full path to the ``gcov`` command on the machine.
    Default is the value of :variable:`CTEST_COVERAGE_COMMAND`.

  ``GCOV_OPTIONS <options>...``
    Specify options to be passed to gcov.  The ``gcov`` command
    is run as ``gcov <options>... -o <gcov-dir> <file>.gcda``.
    If not specified, the default option is just ``-b -x``.

  ``GLOB``
    .. versionadded:: 3.6

    Recursively search for ``.gcda`` files in ``<build-dir>`` rather than
    determining search locations by reading ``CMakeFiles/TargetDirectories.txt``
    (file generated by CMake at the generation phase).

  ``DELETE``
    .. versionadded:: 3.6

    Delete coverage files after they've been packaged into the ``.tar``.

  ``QUIET``
    Suppress non-error messages that otherwise would have been
    printed out by this command.

  .. versionadded:: 3.3
    Added support for the :variable:`CTEST_CUSTOM_COVERAGE_EXCLUDE` variable.

Examples
^^^^^^^^

Generating code coverage data packaged as a ``.tar.gz`` file in a
:option:`ctest -S` script:

.. code-block:: cmake
  :caption: ``script.cmake``

  include(CTestCoverageCollectGCOV)

  ctest_coverage_collect_gcov(
    TARBALL "${CTEST_BINARY_DIRECTORY}/gcov.tar.gz"
    TARBALL_COMPRESSION "GZIP"
  )
#]=======================================================================]

function(ctest_coverage_collect_gcov)
  set(options QUIET GLOB DELETE)
  set(oneValueArgs TARBALL SOURCE BUILD GCOV_COMMAND TARBALL_COMPRESSION)
  set(multiValueArgs GCOV_OPTIONS)
  cmake_parse_arguments(GCOV  "${options}" "${oneValueArgs}"
    "${multiValueArgs}" "" ${ARGN} )
  if(NOT DEFINED GCOV_TARBALL)
    message(FATAL_ERROR
      "TARBALL must be specified. for ctest_coverage_collect_gcov")
  endif()
  if(NOT DEFINED GCOV_SOURCE)
    set(source_dir "${CTEST_SOURCE_DIRECTORY}")
  else()
    set(source_dir "${GCOV_SOURCE}")
  endif()
  if(NOT DEFINED GCOV_BUILD)
    set(binary_dir "${CTEST_BINARY_DIRECTORY}")
  else()
    set(binary_dir "${GCOV_BUILD}")
  endif()
  if(NOT DEFINED GCOV_GCOV_COMMAND)
    set(gcov_command "${CTEST_COVERAGE_COMMAND}")
  else()
    set(gcov_command "${GCOV_GCOV_COMMAND}")
  endif()
  set(supported_compressions "GZIP" "BZIP2" "LZMA" "LZMA2" "XZ" "ZSTD" "FROM_EXT")
  if(NOT DEFINED GCOV_TARBALL_COMPRESSION)
    set(GCOV_TARBALL_COMPRESSION "BZIP2")
  elseif( GCOV_TARBALL_COMPRESSION AND
      NOT GCOV_TARBALL_COMPRESSION IN_LIST supported_compressions)
    message(FATAL_ERROR "TARBALL_COMPRESSION must be OFF or one of ${supported_compressions} for ctest_coverage_collect_gcov")
  endif()
  # run gcov on each gcda file in the binary tree
  set(gcda_files)
  set(label_files)
  if (GCOV_GLOB)
      file(GLOB_RECURSE gfiles "${binary_dir}/*.gcda")
      list(LENGTH gfiles len)
      # if we have gcda files then also grab the labels file for that target
      if(${len} GREATER 0)
        file(GLOB_RECURSE lfiles RELATIVE ${binary_dir} "${binary_dir}/Labels.json")
        list(APPEND gcda_files ${gfiles})
        list(APPEND label_files ${lfiles})
      endif()
  else()
    # look for gcda files in the target directories
    # this will be faster and only look where the files will be
    file(STRINGS "${binary_dir}/CMakeFiles/TargetDirectories.txt" target_dirs
         ENCODING UTF-8)
    foreach(target_dir ${target_dirs})
      file(GLOB_RECURSE gfiles "${target_dir}/*.gcda")
      list(LENGTH gfiles len)
      # if we have gcda files then also grab the labels file for that target
      if(${len} GREATER 0)
        file(GLOB_RECURSE lfiles RELATIVE ${binary_dir}
          "${target_dir}/Labels.json")
        list(APPEND gcda_files ${gfiles})
        list(APPEND label_files ${lfiles})
      endif()
    endforeach()
  endif()
  # return early if no coverage files were found
  list(LENGTH gcda_files len)
  if(len EQUAL 0)
    if (NOT GCOV_QUIET)
      message("ctest_coverage_collect_gcov: No .gcda files found, "
        "ignoring coverage request.")
    endif()
    return()
  endif()
  # setup the dir for the coverage files
  set(coverage_dir "${binary_dir}/Testing/CoverageInfo")
  file(MAKE_DIRECTORY  "${coverage_dir}")
  # run gcov, this will produce the .gcov files in the current
  # working directory
  if(NOT DEFINED GCOV_GCOV_OPTIONS)
    set(GCOV_GCOV_OPTIONS -b -x)
  endif()
  if (GCOV_QUIET)
    set(coverage_out_opts
      OUTPUT_QUIET
      ERROR_QUIET
      )
  else()
    set(coverage_out_opts
      OUTPUT_FILE "${coverage_dir}/gcov.log"
      ERROR_FILE  "${coverage_dir}/gcov.log"
      )
  endif()
  execute_process(COMMAND
    ${gcov_command} ${GCOV_GCOV_OPTIONS} ${gcda_files}
    RESULT_VARIABLE res
    WORKING_DIRECTORY ${coverage_dir}
    ${coverage_out_opts}
    )

  if (GCOV_DELETE)
    file(REMOVE ${gcda_files})
  endif()

  if(NOT "${res}" EQUAL 0)
    if (NOT GCOV_QUIET)
      message(STATUS "Error running gcov: ${res}, see\n  ${coverage_dir}/gcov.log")
    endif()
  endif()
  # create json file with project information
  file(WRITE ${coverage_dir}/data.json
    "{
    \"Source\": \"${source_dir}\",
    \"Binary\": \"${binary_dir}\"
}")
  # collect the gcov files
  set(unfiltered_gcov_files)
  file(GLOB_RECURSE unfiltered_gcov_files RELATIVE ${binary_dir} "${coverage_dir}/*.gcov")

  # if CTEST_EXTRA_COVERAGE_GLOB was specified we search for files
  # that might be uncovered
  if (DEFINED CTEST_EXTRA_COVERAGE_GLOB)
    set(uncovered_files)
    foreach(search_entry IN LISTS CTEST_EXTRA_COVERAGE_GLOB)
      if(NOT GCOV_QUIET)
        message("Add coverage glob: ${search_entry}")
      endif()
      file(GLOB_RECURSE matching_files "${source_dir}/${search_entry}")
      if (matching_files)
        list(APPEND uncovered_files "${matching_files}")
      endif()
    endforeach()
  endif()

  set(gcov_files)
  foreach(gcov_file ${unfiltered_gcov_files})
    file(STRINGS ${binary_dir}/${gcov_file} first_line LIMIT_COUNT 1 ENCODING UTF-8)

    set(is_excluded false)
    if(first_line MATCHES "^        -:    0:Source:(.*)$")
      set(source_file ${CMAKE_MATCH_1})
    elseif(NOT GCOV_QUIET)
      message(STATUS "Could not determine source file corresponding to: ${gcov_file}")
    endif()

    foreach(exclude_entry IN LISTS CTEST_CUSTOM_COVERAGE_EXCLUDE)
      if(source_file MATCHES "${exclude_entry}")
        set(is_excluded true)

        if(NOT GCOV_QUIET)
          message("Excluding coverage for: ${source_file} which matches ${exclude_entry}")
        endif()

        break()
      endif()
    endforeach()

    get_filename_component(resolved_source_file "${source_file}" ABSOLUTE)
    foreach(uncovered_file IN LISTS uncovered_files)
      get_filename_component(resolved_uncovered_file "${uncovered_file}" ABSOLUTE)
      if (resolved_uncovered_file STREQUAL resolved_source_file)
        list(REMOVE_ITEM uncovered_files "${uncovered_file}")
      endif()
    endforeach()

    if(NOT is_excluded)
      list(APPEND gcov_files ${gcov_file})
    endif()
  endforeach()

  foreach (uncovered_file ${uncovered_files})
    # Check if this uncovered file should be excluded.
    set(is_excluded false)
    foreach(exclude_entry IN LISTS CTEST_CUSTOM_COVERAGE_EXCLUDE)
      if(uncovered_file MATCHES "${exclude_entry}")
        set(is_excluded true)
        if(NOT GCOV_QUIET)
          message("Excluding coverage for: ${uncovered_file} which matches ${exclude_entry}")
        endif()
        break()
      endif()
    endforeach()
    if(is_excluded)
      continue()
    endif()

    # Copy from source to binary dir, preserving any intermediate subdirectories.
    get_filename_component(filename "${uncovered_file}" NAME)
    get_filename_component(relative_path "${uncovered_file}" DIRECTORY)
    string(REPLACE "${source_dir}" "" relative_path "${relative_path}")
    if (relative_path)
      # Strip leading slash.
      string(SUBSTRING "${relative_path}" 1 -1 relative_path)
    endif()
    file(COPY ${uncovered_file} DESTINATION ${binary_dir}/uncovered/${relative_path})
    if(relative_path)
      list(APPEND uncovered_files_for_tar uncovered/${relative_path}/${filename})
    else()
      list(APPEND uncovered_files_for_tar uncovered/${filename})
    endif()
  endforeach()

  # tar up the coverage info with the same date so that the md5
  # sum will be the same for the tar file independent of file time
  # stamps
  string(REPLACE ";" "\n" gcov_files "${gcov_files}")
  string(REPLACE ";" "\n" label_files "${label_files}")
  string(REPLACE ";" "\n" uncovered_files_for_tar "${uncovered_files_for_tar}")
  file(WRITE "${coverage_dir}/coverage_file_list.txt"
    "${gcov_files}
${coverage_dir}/data.json
${label_files}
${uncovered_files_for_tar}
")

  # Prepare tar command line arguments

  set(tar_opts "")
  set(zstd_tar_opt "")
  # Select data compression mode
  if( GCOV_TARBALL_COMPRESSION STREQUAL "FROM_EXT")
    if( GCOV_TARBALL MATCHES [[\.(tgz|tar.gz)$]] )
      string(APPEND tar_opts "z")
    elseif( GCOV_TARBALL MATCHES [[\.(txz|tar.xz)$]] )
      string(APPEND tar_opts "J")
    elseif( GCOV_TARBALL MATCHES [[\.(tbz|tar.bz)$]] )
      string(APPEND tar_opts "j")
    elseif( GCOV_TARBALL MATCHES [[\.(tlzma|tar.lzma)$]] )
      set(zstd_tar_opt "--lzma")
    elseif( GCOV_TARBALL MATCHES [[\.(tzst|tar.zst)$]] )
      set(zstd_tar_opt "--zstd")
    endif()
  elseif(GCOV_TARBALL_COMPRESSION STREQUAL "GZIP")
    string(APPEND tar_opts "z")
  elseif((GCOV_TARBALL_COMPRESSION STREQUAL "XZ") OR (GCOV_TARBALL_COMPRESSION STREQUAL "LZMA2"))
    string(APPEND tar_opts "J")
  elseif(GCOV_TARBALL_COMPRESSION STREQUAL "BZIP2")
    string(APPEND tar_opts "j")
  elseif(GCOV_TARBALL_COMPRESSION STREQUAL "ZSTD")
    set(zstd_tar_opt "--zstd")
  elseif(GCOV_TARBALL_COMPRESSION STREQUAL "LZMA")
    set(zstd_tar_opt "--lzma")
  endif()
  # Verbosity options
  if(NOT GCOV_QUIET AND NOT tar_opts MATCHES v)
    string(APPEND tar_opts "v")
  endif()
  # Prepend option 'c' specifying 'create'
  string(PREPEND tar_opts "c")
  # Append option 'f' so that the next argument is the filename
  string(APPEND tar_opts "f")

  execute_process(COMMAND
    ${CMAKE_COMMAND} -E tar ${tar_opts} ${GCOV_TARBALL} ${zstd_tar_opt}
    "--mtime=1970-01-01 0:0:0 UTC"
    "--format=gnutar"
    --files-from=${coverage_dir}/coverage_file_list.txt
    WORKING_DIRECTORY ${binary_dir})

  if (GCOV_DELETE)
    foreach(gcov_file ${unfiltered_gcov_files})
      file(REMOVE ${binary_dir}/${gcov_file})
    endforeach()
    file(REMOVE ${coverage_dir}/coverage_file_list.txt)
    file(REMOVE ${coverage_dir}/data.json)
    if (EXISTS ${binary_dir}/uncovered)
      file(REMOVE ${binary_dir}/uncovered)
    endif()
  endif()

endfunction()
