melexis/sphinx-traceability-extension

View on GitHub
mlx/traceability/directives/item_matrix_directive.py

Summary

Maintainability
B
4 hrs
Test Coverage
"""Module for the item-matrix directive"""
import re
from collections import namedtuple
from copy import copy, deepcopy

from docutils import nodes
from docutils.parsers.rst import directives
from natsort import natsorted, natsort_keygen

from ..traceability_exception import TraceabilityException, report_warning
from ..traceable_base_directive import TraceableBaseDirective
from ..traceable_base_node import TraceableBaseNode
from ..traceable_item import TraceableItem

natsort_key = natsort_keygen(key=lambda item: getattr(item, 'identifier', ''))


def group_choice(argument):
    """Conversion function for the "group" option."""
    return directives.choice(argument, ('top', 'bottom'))


class ItemMatrix(TraceableBaseNode):
    '''Matrix for cross referencing documentation items'''

    COVERAGE_REGEX = re.compile(r'([><]=?|==|!=)\s*[\d\./]+')

    def perform_replacement(self, app, collection):
        """
        Creates table with related items, printing their target references. Only source and target items matching
        respective regexp shall be included.

        Args:
            app: Sphinx application object to use.
            collection (TraceableCollection): Collection for which to generate the nodes.
        """
        Rows = namedtuple('Rows', "sorted covered uncovered counters")
        filters = {x: None for x in ['source', 'target']}
        if self['filtertarget']:
            filters['target'] = self['filter-attributes']
        else:
            filters['source'] = self['filter-attributes']
        source_ids = collection.get_items(self['source'], attributes=filters['source'])
        targets_with_ids = []
        for target_regex in self['target']:
            targets_with_ids.append(collection.get_items(target_regex, attributes=filters['target']))
        top_node = self.create_top_node(self['title'], hide_title=self['hidetitle'])
        table = nodes.table()
        if self.get('classes'):
            table.get('classes').extend(self.get('classes'))

        # Column and heading setup
        titles = [nodes.paragraph('', title) for title in [self['sourcetitle'], *self['targettitle']]]

        if self['hidetarget']:
            titles = titles[0]
        for value in reversed(self['sourcecolumns']):
            if value in TraceableItem.defined_attributes:
                titles.insert(1, self.make_attribute_ref(app, value))
            else:
                titles.insert(1, nodes.paragraph('', value))
        for value in self['targetcolumns']:
            if value in TraceableItem.defined_attributes:
                titles.append(self.make_attribute_ref(app, value))
            else:
                titles.append(nodes.paragraph('', app.config.traceability_relationship_to_string[value]))
        show_intermediate = bool(self['intermediatetitle']) and bool(self['intermediate'])
        if show_intermediate:
            titles.insert(1 + len(self['sourcecolumns']), nodes.paragraph('', self['intermediatetitle']))
        if self['hidesource']:
            titles.pop(0)
        headings = [nodes.entry('', title) for title in titles]
        number_of_columns = len(titles)
        tgroup = nodes.tgroup()
        tgroup += [nodes.colspec(colwidth=5) for _ in range(number_of_columns)]
        tgroup += nodes.thead('', nodes.row('', *headings))
        table += tgroup

        # External relationships are treated a bit special in item-matrices:
        # - External references are only shown if explicitly requested in the "type" configuration
        # - No target filtering is done on external references
        mapping_via_intermediate = {}
        if not self['type']:
            # if no explicit relationships were given, we consider all of them (except for external ones)
            relationships = [rel for rel in collection.iter_relations() if not self.is_relation_external(rel)]
            external_relationships = []
        else:
            relationships = self['type'].split(' ')
            external_relationships = [rel for rel in relationships if self.is_relation_external(rel)]
            if ' | ' in self['type']:
                mapping_via_intermediate = self.linking_via_intermediate(source_ids, targets_with_ids, collection)

        duplicate_count_total = 0
        duplicate_count_covered = 0
        rows = Rows([], [], [], [0, 0])
        for source_id in source_ids:
            source_item = collection.get_item(source_id)
            if self['sourcetype'] and not source_item.has_relations(self['sourcetype']):
                continue
            covered = False
            rights = [[] for _ in range(int(bool(self['intermediate'])) + len(self['target']))]
            if mapping_via_intermediate:
                intermediates = mapping_via_intermediate[source_id]
                if not intermediates:
                    self._store_data(rows, source_item, rights, False, app)
                    continue
                covered_intermediates = {intermediate: any(target_set for target_set in target_sets)
                                         for intermediate, target_sets in intermediates.items()}

                if self['coveredintermediates']:
                    covered = all(covered_intermediates.values())
                else:
                    covered = any(covered_intermediates.values())

                if self['splitintermediates']:
                    for intermediate, target_sets in intermediates.items():
                        self._store_row_with_intermediate({intermediate: target_sets},
                                                          rows, source_item, rights, covered, app)
                    duplicate_count_for_source = len(intermediates) - 1
                    duplicate_count_total += duplicate_count_for_source
                    duplicate_count_covered += duplicate_count_for_source if covered else 0
                else:
                    self._store_row_with_intermediate({intermediate: target_sets for intermediate, target_sets
                                                       in intermediates.items()
                                                       if covered and covered_intermediates[intermediate]},
                                                      rows, source_item, rights, covered, app)
            else:
                has_external_target = self.add_external_targets(rights, source_item, external_relationships, app)
                has_internal_target = self.add_internal_targets(rights, source_id, targets_with_ids, relationships,
                                                                collection)
                covered = has_external_target or has_internal_target
                self._store_data(rows, source_item, rights, covered, app)

        if not source_ids:
            # try to use external targets as source
            for ext_rel in external_relationships:
                external_targets = collection.get_external_targets(self['source'], ext_rel)
                # natural sorting on source
                for ext_source, target_ids in natsorted(external_targets.items()):
                    covered = False
                    source_link = self.make_external_item_ref(app, ext_source, ext_rel)
                    rights = [[] for _ in range(len(self['target']))]
                    target_items = [collection.get_item(id_) for id_ in target_ids]
                    covered = self._add_target_items(rights, target_items)
                    self._store_data(rows, source_link, rights, covered, app)

        tgroup += self._build_table_body(rows, self['group'], self['onlycovered'], self['onlyuncovered'])

        count_total = rows.counters[0] + rows.counters[1] - duplicate_count_total
        count_covered = rows.counters[0] - duplicate_count_covered
        try:
            percentage = 100 * count_covered / count_total
        except ZeroDivisionError:
            percentage = 0
        self._check_coverage(percentage)

        if self['stats']:
            disp = 'Statistics: {cover} out of {total} covered: {pct}%'.format(cover=count_covered,
                                                                               total=count_total,
                                                                               pct=int(percentage))
            if self['onlycovered']:
                disp += ' (uncovered items are hidden)'
            elif self['onlyuncovered']:
                disp += ' (covered items are hidden)'
            p_node = nodes.paragraph()
            txt = nodes.Text(disp)
            p_node += txt
            top_node += p_node

        if number_of_columns:
            top_node += table
        self.replace_self(top_node)

    def _build_table_body(self, rows, group, onlycovered, onlyuncovered):
        """ Creates the table body and fills it with rows, grouping and excluding uncovered source items when desired

        Args:
            rows (Rows): Rows namedtuple object
            group (str): Group option, falsy to disable grouping, 'top' or 'bottom' otherwise
            onlycovered (bool): True to only include source items that are covered; False otherwise
            onlyuncovered (bool): True to only include source items that are uncovered; False otherwise

        Returns:
            nodes.tbody: Filled table body
        """
        tbody = nodes.tbody()
        if onlycovered:
            tbody += rows.covered
        elif onlyuncovered:
            tbody += rows.uncovered
        elif not group:
            tbody += rows.sorted
        elif group == 'top':
            tbody += rows.uncovered
            tbody += rows.covered
        elif group == 'bottom':
            tbody += rows.covered
            tbody += rows.uncovered

        self._postprocess_tbody(tbody)

        return tbody

    def _postprocess_tbody(self, tbody):
        """ Merges cells where appropriate to avoid duplication and removes certain columns depending on configuration

        Args:
            tbody (nodes.tbody): Table body to modify
        """
        indexes_to_merge = range(1 + len(self['sourcecolumns']) + int(bool(self['intermediate'])))
        cells_to_remove = self._set_rowspan(tbody, indexes_to_merge)

        intermediate_idx = indexes_to_merge[-1] if self['intermediate'] else None
        target_idxes = []
        for idx in reversed(range(len(self['target']))):
            target_idxes.append(-1 * (idx + 1) * (1 + len(self['targetcolumns'])))
        for row_idx, row in enumerate(tbody):
            # order of if-statements below is important: remove cells from right to left
            if self['intermediate'] and (not self['intermediatetitle'] or intermediate_idx in cells_to_remove[row_idx]):
                row.pop(intermediate_idx)
            for idx in reversed(range(1, 1 + len(self['sourcecolumns']))):
                if idx in cells_to_remove[row_idx]:
                    row.pop(idx)
            if self['hidesource'] or 0 in cells_to_remove[row_idx]:
                row.pop(0)
            if self['hidetarget']:
                for idx in target_idxes:
                    row.pop(idx)

    @staticmethod
    def _set_rowspan(tbody, indexes):
        """ Sets the 'rowspan' attribute of cells that should span multiple rows to avoid duplication

        Also groups all rows that belong to a single source item by assigning them to the same CSS class.

        Args:
            tbody (nodes.tbody): Table body
            indexes (iterable): Range object with indexes of columns to take into account

        Returns:
            dict: Mapping of row indices to list of column indices, of cells that shall be removed from the table body
        """
        prev_row = None
        cells_to_remove = {}
        original_cells = {idx: None for idx in indexes}
        group_class_nr = 0
        for row_idx, row in enumerate(tbody):
            cells_to_remove[row_idx] = []
            if prev_row is None:
                prev_row = row
                row['classes'].append(f'item-group-{group_class_nr}')
                continue

            for col_idx, cell in original_cells.items():
                if str(row[col_idx]) == str(prev_row[col_idx]):
                    if cell is None:
                        original_cells[col_idx] = prev_row[col_idx]  # do not set `cell`
                    original_cells[col_idx]['morerows'] = 1 + original_cells[col_idx].get('morerows', 0)
                    cells_to_remove[row_idx].append(col_idx)
                elif col_idx == 0:  # new source so reset and move on to next row
                    original_cells = {idx: None for idx in indexes}
                    group_class_nr += 1
                    break
                else:
                    original_cells[col_idx] = None

            prev_row = row
            row['classes'].append(f'item-group-{group_class_nr}')
        return cells_to_remove

    @staticmethod
    def add_all_targets(right_cells, linked_items):
        """ Adds intermediate items followed by internal target items

        Args:
            right_cells (list): List of empty lists to fill with intermediates items followed by target items
            linked_items (dict): Mapping of intermediate items to the list of sets of target items per target
        """
        # avoid duplicate target IDs in the same cell due to multiple intermediates with the same target item
        added_items_per_column = {}
        for intermediate_item, targets in linked_items.items():
            right_cells[0].append(intermediate_item)
            for idx, target_items in enumerate(targets, start=1):
                if idx not in added_items_per_column:
                    added_items_per_column[idx] = set()
                for target_item in target_items.difference(added_items_per_column[idx]):
                    right_cells[idx].append(target_item)
                    added_items_per_column[idx].add(target_item)
                right_cells[idx].sort(key=natsort_key)
        right_cells[0].sort(key=natsort_key)

    def add_external_targets(self, right_cells, source_item, external_relationships, app):
        """ Adds links to external targets for given source to the list of data per column

        Args:
            right_cells (list): List of lists to add external target link(s) to when covered
            source_item (TraceableItem): Source item
            external_relationships (list): List of all valid external relationships between source and target(s)
            app (sphinx.application.Sphinx): Sphinx application object

        Returns:
            bool: True if one or more external targets have been found for the given source item, False otherwise
        """
        has_external_target = False
        for external_relationship in external_relationships:
            for target_id in source_item.yield_targets_sorted(external_relationship):
                ext_item_ref = self.make_external_item_ref(app, target_id, external_relationship)
                for cell in right_cells:
                    cell.append(ext_item_ref)
                has_external_target = True
        return has_external_target

    @staticmethod
    def add_internal_targets(right_cells, source_id, targets_with_ids, relationships, collection):
        """ Adds internal target items for given source to the list of data per column

        Args:
            right_cells (list): List of lists to add target items to when covered
            source_id (str): Item ID of source item
            targets_with_ids (list): List of lists per target, listing target IDs to take into consideration
            relationships (list): List of all valid relationships between source and target(s)
            collection (TraceableCollection): Collection of TraceableItems

        Returns:
            bool: True if one or more internal targets have been found for the given source item, False otherwise
        """
        has_internal_target = False
        for idx, target_ids in enumerate(targets_with_ids):
            for target_id in target_ids:
                if collection.are_related(source_id, relationships, target_id):
                    right_cells[idx].append(collection.get_item(target_id))
                    has_internal_target = True
        return has_internal_target

    def linking_via_intermediate(self, source_ids, targets_with_ids, collection):
        """ Maps source IDs to IDs of target items that are linked via an itermediate item per target

        Args:
            source_ids (list): List of item IDs of source items
            targets_with_ids (list): List of lists, which contain target IDs to take into consideration, per target
            collection (TraceableCollection): Collection of TraceableItems

        Returns:
            dict: Mapping of source IDs as key with as value a mapping of intermediate items to
                the list of sets of target items per target
        """
        links_with_relations = []
        for relationships_str in self['type'].split(' | '):
            links_with_relations.append(relationships_str.split(' '))
        if len(links_with_relations) > 2:
            raise TraceabilityException("Type option of item-matrix must not contain more than one '|' "
                                        "character; got {}".format(self['type']),
                                        docname=self["document"])
        # reverse relationship(s) specified for linking source to intermediate
        for idx, rel in enumerate(links_with_relations[0]):
            links_with_relations[0][idx] = collection.get_reverse_relation(rel)

        source_to_links_map = {source_id: {} for source_id in source_ids}
        for intermediate_id in collection.get_items(self['intermediate'], sort=bool(self['intermediatetitle'])):
            intermediate_item = collection.get_item(intermediate_id)
            potential_source_ids = set()
            for reverse_rel in links_with_relations[0]:
                potential_source_ids.update(intermediate_item.yield_targets(reverse_rel))
            # apply :source: filter
            potential_source_ids = potential_source_ids.intersection(source_ids)
            if not potential_source_ids:  # move to the next intermediate candidate to save resources
                continue

            potential_target_ids = set()
            target_ids, uncovered_items = self._determine_targets(intermediate_item, links_with_relations[1],
                                                                  collection)
            potential_target_ids.update(target_ids)
            # apply :target: filter
            actual_targets = []
            for target_ids in targets_with_ids:
                linked_target_ids = potential_target_ids.intersection(target_ids)
                actual_targets.append(set(collection.get_item(id_) for id_ in linked_target_ids))

            self._store_targets(source_to_links_map, potential_source_ids, actual_targets, intermediate_item)
            for item in uncovered_items:
                self._store_targets(source_to_links_map, potential_source_ids, [], item)
        return source_to_links_map

    def _determine_targets(self, item, relations, collection):
        """ Determines all potential targets of a given intermediate item and forward relations.

        Note: This function is recursively called when the option 'recursiveintermediates' is used and a suitable
        nested intermediate item was found via the specified relation for recursion.
        """
        all_uncovered_items = set()
        potential_target_ids = set(item.yield_targets(*relations))
        if self['recursiveintermediates']:
            for nested_item_id in item.yield_targets(self['recursiveintermediates']):
                nested_item = collection.items[nested_item_id]
                if nested_item.is_match(self['intermediate']):
                    new_target_ids, _ = self._determine_targets(nested_item, relations, collection)
                    potential_target_ids.update(new_target_ids)
                    if not new_target_ids:
                        all_uncovered_items.add(nested_item)
        return potential_target_ids, all_uncovered_items

    @staticmethod
    def _store_targets(source_to_links_map, source_ids, targets, intermediate_item):
        """ Extends given mapping with target IDs per target as value for each source ID as key

        Args:
            source_to_links_map (dict): Mapping of source IDs as key with as value a mapping of intermediate items to
                the list of sets of target IDs per target
            source_ids (set): Source IDs to store targets for
            targets (list): List of linked target items (set) per target
            intermediate_item (TraceableItem): Intermediate item that links the given source items to the given target
                items
        """
        for source_id in source_ids:
            if source_id not in source_to_links_map:
                source_to_links_map[source_id] = {}
            source_to_links_map[source_id][intermediate_item] = targets

    def _store_row_with_intermediate(self, linked_items, rows, source, empty_right_cells, *args):
        """ Stores a row for a source, linking targets via one or all intermediates

        Args:
            linked_items (dict): Mapping of one or all intermediate IDs to the list of sets of target items per target
            rows (Rows): Rows namedtuple object to extend
            source (TraceableItem): Source item
            empty_right_cells (list): List of empty lists to fill with intermediates items, followed by target items
        """
        right_cells = deepcopy(empty_right_cells)
        self.add_all_targets(right_cells, linked_items)
        self._store_data(rows, source, right_cells, *args)

    def _store_data(self, rows, source, right_cells, covered, app):
        """ Stores the data in one or more rows in the given Rows object.

        Note that merging and removing cells happens in a later stage.

        Args:
            rows (Rows): Rows namedtuple object to extend
            source (TraceableItem|nodes.paragraph): Traceable source item or paragraph with link to it
            right_cells (list): List of lists with intermediate or target items or paragraphs with a link to them
            covered (bool): True if the row shall be stored in the covered attribute, False for uncovered attribute
            app (sphinx.application.Sphinx): Sphinx application object
        """
        source_attribute_cells = self._create_cells_for_info_cols(source, self['sourcecolumns'], app)
        has_intermediate = bool(self['intermediate'])
        intermediate_items = []
        if has_intermediate:
            intermediate_items = right_cells.pop(0)
        targets_per_target = right_cells

        new_rows = []
        number_of_rows = 1
        if self['splittargets']:
            number_of_rows = max([1] + [len(targets) for targets in targets_per_target])
        for row_idx in range(number_of_rows):
            row = nodes.row()

            # source
            row += self._create_cell_for_items([source], app)
            # source columns: attributes and extra relations
            for cell in source_attribute_cells:
                row += copy(cell)
            # intermediate
            if has_intermediate:
                if intermediate_items:
                    row += self._create_cell_for_items(intermediate_items, app)
                else:
                    row += nodes.entry('')
            # targets
            for target_items in targets_per_target:
                items = [nodes.paragraph('')]
                if number_of_rows == 1 and target_items:
                    items = target_items
                elif row_idx < len(target_items):
                    items = [target_items[row_idx]]
                items.sort(key=natsort_key)
                row += self._create_cell_for_items(items, app)
            # target columns: attributes and extra relations
            target_attribute_cells = []
            if self['targetcolumns']:
                if targets_per_target[-1]:
                    target_item = targets_per_target[-1][row_idx]
                else:
                    target_item = nodes.paragraph('')
                target_attribute_cells = self._create_cells_for_info_cols(target_item, self['targetcolumns'], app)
            row += target_attribute_cells

            new_rows.append(row)

        if covered:
            rows.counters[0] += 1
            rows.covered.extend(new_rows)
            rows.sorted.extend(new_rows)
        else:
            rows.counters[1] += 1
            rows.uncovered.extend(new_rows)
            rows.sorted.extend(new_rows)

    def _add_target_items(self, target_cells, target_items):
        """ Stores target items after filtering by target option.

        Returns whether the source has been covered or not.

        Args:
            target_cells (list): List of empty lists to fill
            target_items (list): List of potential target items

        Returns:
            bool: True if a target item has been stored, False otherwise
        """
        covered = False
        for idx, target_regex in enumerate(self['target']):
            for target in target_items:
                if target_regex and target_regex.match(target.identifier):
                    target_cells[idx].append(target)
                    covered = True
        return covered

    def _create_cells_for_info_cols(self, item, values, app):
        """ Creates a cell with the item's attribute value for each attribute in the given list.

        Args:
            item (TraceableItem): TraceableItem instance
            values (list): List of attributes and/or relationships (str)
            app: Sphinx' application object to use.

        Returns:
            list[nodes.entry]: Cells filled with attribute values for the given item
        """
        cells = []
        for value in values:
            if value in TraceableItem.defined_attributes:
                cells.append(self._create_cell_for_attribute(item, value))
            else:
                cells.append(self._create_cell_for_relation(item, value, app))
        return cells

    def _create_cell_for_relation(self, item, relation, app):
        """ Creates a cell with linked items via the given relation.

        Args:
            item (TraceableItem): TraceableItem instance
            relation (str): Relation for which to get the linked items
            app: Sphinx' application object to use.

        Returns:
            nodes.entry: Cell filled with attribute value for the given item
        """
        cell = nodes.entry('')
        if not isinstance(item, nodes.paragraph):
            for linked_item in item.yield_targets_sorted(relation):
                if self.is_relation_external(relation):
                    cell += self.make_external_item_ref(app, linked_item, relation)
                else:
                    cell += self.make_internal_item_ref(app, linked_item)
        return cell

    def _check_coverage(self, percentage):
        """ Checks the coverage percentage using the configured expression

        A warning is reported when the configured expression is invalid or if the evaluation returns False.

        Args:
            percentage (float): Coverage percentage
        """
        if self['coverage']:
            pattern = r'([><]=?|==|!=)\s*[\d\./]+'
            if self.COVERAGE_REGEX.fullmatch(self['coverage']):
                expression = '{} {}'.format(percentage, self['coverage'])
                if not eval(expression):  # pylint: disable=eval-used
                    report_warning('Item-matrix with title {!r} has bad coverage: {} evaluates to False'
                                   .format(self['title'], expression),
                                   docname=self['document'],
                                   lineno=self['line'])
            else:
                report_warning('Expected value for coverage option to fully match regex {}; got {!r}'
                               .format(pattern, self['coverage']),
                               docname=self['document'],
                               lineno=self['line'])


class ItemMatrixDirective(TraceableBaseDirective):
    """
    Directive to generate a matrix of item cross-references, based on
    a given set of relationship types.

    Syntax::

      .. item-matrix:: title
         :target: regexp ...
         :source: regexp
         :intermediate: regexp
         :<<attribute>>: regexp
         :targettitle: Target column header(s)
         :sourcetitle: Source column header
         :intermediatetitle: Intermediate column header
         :type: <<relationship>> ...
         :sourcetype: <<relationship>> ...
         :sourcecolumns: <<attribute>> ...
         :targetcolumns: <<attribute>> ...
         :hidesource:
         :hidetarget:
         :splitintermediates:
         :splittargets:
         :group: top | bottom
         :onlycovered:
         :onlyuncovered:
         :stats:
         :coverage: Evaluation, e.g. >=95
         :nocaptions:
         :onlycaptions:
         :hidetitle:
         :filtertarget:
    """
    # Optional argument: title (whitespace allowed)
    optional_arguments = 1
    # Options
    option_spec = {
        'class': directives.class_option,
        'target': directives.unchanged,
        'source': directives.unchanged,
        'intermediate': directives.unchanged,
        'targettitle': directives.unchanged,
        'sourcetitle': directives.unchanged,
        'intermediatetitle': directives.unchanged,
        'type': directives.unchanged,  # relationship types separated by space
        'sourcetype': directives.unchanged,  # relationship types separated by space
        'sourcecolumns': directives.unchanged,  # attributes separated by space
        'targetcolumns': directives.unchanged,  # attributes separated by space
        'hidesource': directives.flag,
        'hidetarget': directives.flag,
        'splitintermediates': directives.flag,
        'splittargets': directives.flag,
        'group': group_choice,
        'onlycovered': directives.flag,
        'onlyuncovered': directives.flag,
        'coveredintermediates': directives.flag,
        'recursiveintermediates': directives.unchanged,
        'stats': directives.flag,
        'coverage': directives.unchanged,
        'nocaptions': directives.flag,
        'onlycaptions': directives.flag,
        'hidetitle': directives.flag,
        'filtertarget': directives.flag,
    }
    # Content disallowed
    has_content = False

    def run(self):
        env = self.state.document.settings.env
        app = env.app

        node = ItemMatrix('')
        node['document'] = env.docname
        node['line'] = self.lineno

        if self.options.get('class'):
            node.get('classes').extend(self.options.get('class'))

        self.process_title(node, 'Traceability matrix of items')

        self.add_found_attributes(node, is_pattern=True)

        self.process_options(
            node,
            {
                'target':            {'default': [''], 'is_pattern': True},
                'intermediate':      {'default': '', 'is_pattern': True},
                'source':            {'default': '', 'is_pattern': True},
                'targettitle':       {'default': ['Target'], 'delimiter': ','},
                'sourcetitle':       {'default': 'Source'},
                'intermediatetitle': {'default': ''},
                'type':              {'default': ''},
                'sourcetype':        {'default': []},
                'recursiveintermediates': {'default': ''},
                'coverage':          {'default': ''},
            },
        )

        if node['intermediate'] and ' | ' not in node['type']:
            raise TraceabilityException("The :intermediate: option is used, expected at least two relationships "
                                        "separated by ' | ' in the :type: option; got {!r}".format(node['type']),
                                        docname=env.docname)
        if ' | ' in node['type'] and not node['intermediate']:
            raise TraceabilityException("The value of the :type: option contains the '|' character,  but the option "
                                        ":intermediate: is missing for item-matrix {!r}".format(node['title']),
                                        docname=env.docname)

        # Process ``group`` option, given as a string that is either top or bottom or empty ().
        node['group'] = self.options.get('group', '')

        number_of_targets = len(node['target'])
        number_of_targettitles = len(node['targettitle'])
        if number_of_targets != number_of_targettitles:
            raise TraceabilityException(
                "Item-matrix directive should have the same number of values for the options 'target' and "
                "'targettitle'. Got target: {targets!r} and targettitle: {titles!r}"
                .format(targets=self.options.get('target', ''), titles=self.options.get('targettitle', '')),
                docname=env.docname)

        if node['type']:
            self.check_relationships(node['type'].replace(' | ', ' ').split(' '), env)
        self.check_relationships(node['sourcetype'], env)
        self.add_attributes_and_relations(node, 'sourcecolumns', app.env.traceability_collection.relations)
        self.add_attributes_and_relations(node, 'targetcolumns', app.env.traceability_collection.relations)
        if number_of_targets > 1 and node['targetcolumns']:
            node['targetcolumns'] = []
            raise TraceabilityException(
                "Item-matrix {!r} cannot combine 'targetcolumns' with more than one 'target'; "
                "ignoring 'targetcolumns' option".format(node['title']),
                docname=env.docname)

        self.check_option_presence(node, 'hidesource')
        self.check_option_presence(node, 'hidetarget')
        self.check_option_presence(node, 'splitintermediates')
        self.check_option_presence(node, 'splittargets')
        self.check_option_presence(node, 'onlycovered')
        self.check_option_presence(node, 'onlyuncovered')
        self.check_option_presence(node, 'coveredintermediates')
        self.check_option_presence(node, 'stats')
        self.check_option_presence(node, 'hidetitle')
        self.check_option_presence(node, 'filtertarget')

        if node['onlycovered'] and node['onlyuncovered']:
            raise TraceabilityException(
                "Item-matrix directive cannot combine 'onlycovered' with 'onlyuncovered' flag",
                docname=env.docname)
        if node['intermediatetitle'] and node['recursiveintermediates']:
            raise TraceabilityException(
                "Item-matrix directive cannot combine 'intermediatetitle' with 'recursiveintermediates' flag",
                docname=env.docname)

        if node['targetcolumns']:
            node['splittargets'] = True

        self.check_caption_flags(node, app.config.traceability_matrix_no_captions)

        return [node]