StrictDoc Documentation
strictdoc/backend/sdoc_source_code/reader_robot.py
Source file coverage
Path:
strictdoc/backend/sdoc_source_code/reader_robot.py
Lines:
248
Non-empty lines:
225
Non-empty lines covered with requirements:
225 / 225 (100.0%)
Functions:
14
Functions covered by requirements:
14 / 14 (100.0%)
1
"""
2
@relation(SDOC-SRS-142, SDOC-SRS-148, scope=file)
3
"""
4
 
5
from typing import List, Optional, Union
6
 
7
from robot.api.parsing import (
8
    Comment,
9
    Documentation,
10
    EmptyLine,
11
    ModelVisitor,
12
    Tags,
13
    TestCase,
14
    Token,
15
    get_model,
16
)
17
from robot.parsing.model.statements import Statement
18
 
19
from strictdoc.backend.sdoc_source_code.constants import FunctionAttribute
20
from strictdoc.backend.sdoc_source_code.marker_parser import MarkerParser
21
from strictdoc.backend.sdoc_source_code.models.language import LanguageItem
22
from strictdoc.backend.sdoc_source_code.models.language_item_marker import (
23
    LanguageItemMarker,
24
    RangeMarkerType,
25
)
26
from strictdoc.backend.sdoc_source_code.models.line_marker import LineMarker
27
from strictdoc.backend.sdoc_source_code.models.range_marker import (
28
    RangeMarker,
29
)
30
from strictdoc.backend.sdoc_source_code.models.source_file_info import (
31
    SourceFileTraceabilityInfo,
32
)
33
from strictdoc.backend.sdoc_source_code.models.source_node import SourceNode
34
from strictdoc.backend.sdoc_source_code.parse_context import ParseContext
35
from strictdoc.backend.sdoc_source_code.processors.general_language_marker_processors import (
36
    language_item_marker_processor,
37
    line_marker_processor,
38
    range_marker_processor,
39
    source_file_traceability_info_processor,
40
)
41
from strictdoc.helpers.file_stats import SourceFileStats
42
from strictdoc.helpers.file_system import file_open_read_utf8
43
 
44
 
45
class SdocRelationVisitor(ModelVisitor):  # type: ignore[misc]
46
    """
47
    Create functions from test cases in *.robot files and create markers.
48
 
49
    Note: ModelVisitor reuses ast.NodeVisitor from Python. We rely on following
50
    behavior.
51
    - It traverses depth first. This order is important to get matching marker
52
      pairs on the ParsersContext.marker_stack.
53
    - It doesn't recurse into subtrees if a custom visit_* method is defined.
54
      This is important to avoid duplicated matches.
55
    """
56
 
57
    def __init__(
58
        self,
59
        traceability_info: SourceFileTraceabilityInfo,
60
        parse_context: ParseContext,
61
        custom_tags: Optional[set[str]] = None,
62
    ):
63
        super().__init__()
64
        self.traceability_info = traceability_info
65
        self.parse_context = parse_context
66
        self.custom_tags = custom_tags
67
 
68
    def visit_Comment(self, node: Comment) -> None:
69
        """
70
        Create non-function Marker from Comment outside TestCases.
71
        """
72
        self._visit_possibly_marked_node(node)
73
 
74
    def visit_Documentation(self, node: Documentation) -> None:
75
        """
76
        Create non-function Marker from Documentation outside TestCases.
77
        """
78
        self._visit_possibly_marked_node(node)
79
 
80
    def visit_Tags(self, node: Tags) -> None:
81
        """
82
        Create non-function Marker from Tags outside TestCases.
83
        """
84
        self._visit_possibly_marked_node(node)
85
 
86
    def visit_TestCase(self, node: TestCase) -> None:
87
        """
88
        Create function and non-function Marker from TestCases.
89
        """
90
        trailing_empty_lines = 0
91
        tc_markers: List[
92
            Union[LanguageItemMarker, RangeMarker, LineMarker]
93
        ] = []
94
        tc_source_nodes: List[SourceNode] = []
95
 
96
        for stmt in node.body:
97
            if isinstance(stmt, EmptyLine):
98
                # Trim trailing newlines from test case range.
99
                trailing_empty_lines += 1
100
            else:
101
                trailing_empty_lines = 0
102
 
103
            source_node = self._parse_stmt(
104
                stmt, node.name, node.lineno, node.end_lineno
105
            )
106
            if source_node is not None:
107
                tc_markers.extend(source_node.markers)
108
                if isinstance(stmt, Documentation) and source_node.fields:
109
                    tc_source_nodes.append(source_node)
110
 
111
        language_item_markers = []
112
        for marker_ in tc_markers:
113
            if isinstance(marker_, LanguageItemMarker):
114
                marker_.ng_range_line_begin = node.lineno
115
                marker_.ng_range_line_end = (
116
                    node.end_lineno - trailing_empty_lines
117
                )
118
                language_item_markers.append(marker_)
119
                language_item_marker_processor(marker_, self.parse_context)
120
            elif isinstance(marker_, RangeMarker):
121
                range_marker_processor(marker_, self.parse_context)
122
            elif isinstance(marker_, LineMarker):
123
                line_marker_processor(marker_, self.parse_context)
124
 
125
        self.traceability_info.markers.extend(language_item_markers)
126
        test_case = LanguageItem(
127
            parent=self.traceability_info,
128
            name=node.name,
129
            display_name=node.name,
130
            line_begin=node.lineno,
131
            line_end=node.end_lineno - trailing_empty_lines,
132
            # FIXME: Byte range is currently not used for Robot framework.
133
            code_byte_range=None,
134
            child_functions=[],
135
            markers=language_item_markers,
136
            attributes={FunctionAttribute.DEFINITION},
137
        )
138
        # Link source nodes (parsed from [Documentation] key: value fields)
139
        # to the test case LanguageItem and register them in traceability_info.
140
        for source_node in tc_source_nodes:
141
            source_node.function = test_case
142
        self.traceability_info.source_nodes.extend(tc_source_nodes)
143
        self.traceability_info.functions.append(test_case)
144
 
145
    def _visit_possibly_marked_node(
146
        self, node: Union[Comment, Documentation, Tags]
147
    ) -> None:
148
        source_node = self._parse_stmt(node, None, node.lineno, node.end_lineno)
149
        if source_node is None:
150
            return
151
        for marker_ in source_node.markers:
152
            if (
153
                isinstance(marker_, LanguageItemMarker)
154
                and marker_.scope is RangeMarkerType.FILE
155
            ):
156
                # Outside Test Cases only accept scope=file function markers
157
                marker_.ng_range_line_begin = 1
158
                marker_.ng_range_line_end = (
159
                    self.parse_context.file_stats.lines_total
160
                )
161
                language_item_marker_processor(marker_, self.parse_context)
162
                self.traceability_info.markers.append(marker_)
163
            elif isinstance(marker_, RangeMarker):
164
                range_marker_processor(marker_, self.parse_context)
165
            elif isinstance(marker_, LineMarker):
166
                line_marker_processor(marker_, self.parse_context)
167
 
168
    def _parse_stmt(
169
        self,
170
        stmt: Statement,
171
        entity_name: Optional[str],
172
        line_start: int,
173
        line_end: int,
174
    ) -> Optional[SourceNode]:
175
        if isinstance(stmt, Documentation):
176
            # Documentation is expected to contain relation markers and source nodes.
177
            # FIXME: No writeback support for source nodes, because
178
            #   1) Robot parser doesn't track byte-offsets (lines/columns count characters, not bytes),
179
            #   2) Line continuation format not handled by MarkerParser.
180
            return MarkerParser.parse(
181
                input_string=stmt.value,
182
                line_start=line_start,
183
                line_end=line_end,
184
                comment_byte_range=None,
185
                filename=self.parse_context.filename,
186
                comment_line_start=stmt.lineno,
187
                entity_name=entity_name,
188
                col_offset=stmt.col_offset,
189
                custom_tags=self.custom_tags,
190
            )
191
        elif isinstance(stmt, (Comment, Tags)):
192
            # Expect relation markers but no source nodes in comments and tags to keep things simple.
193
            source_nodes = SourceNode(
194
                entity_name=entity_name, comment_byte_range=None
195
            )
196
            for token in filter(self._token_filter, stmt.tokens):
197
                sn = MarkerParser.parse(
198
                    input_string=token.value,
199
                    line_start=token.lineno,
200
                    line_end=token.lineno,
201
                    comment_byte_range=None,
202
                    filename=self.parse_context.filename,
203
                    comment_line_start=token.lineno,
204
                    entity_name=entity_name,
205
                    col_offset=token.col_offset,
206
                )
207
                source_nodes.markers.extend(sn.markers)
208
            return source_nodes
209
        return None
210
 
211
    @staticmethod
212
    def _token_filter(token: Token) -> bool:
213
        if token.type in ("SEPARATOR", "EOL"):
214
            return False
215
        return True
216
 
217
 
218
class SourceFileTraceabilityReader_Robot:
219
    @staticmethod
220
    def supported_elements() -> list[str]:
221
        return ["testcase"]
222
 
223
    def __init__(self, custom_tags: Optional[set[str]] = None) -> None:
224
        self.custom_tags: Optional[set[str]] = custom_tags
225
 
226
    def read(
227
        self, input_buffer: str, file_path: Optional[str] = None
228
    ) -> SourceFileTraceabilityInfo:
229
        traceability_info = SourceFileTraceabilityInfo([])
230
        if len(input_buffer) == 0:
231
            return traceability_info
232
        file_stats = SourceFileStats.create(input_buffer)
233
        parse_context = ParseContext(file_path, file_stats)
234
        robotfw_model = get_model(input_buffer, data_only=False)
235
        visitor = SdocRelationVisitor(
236
            traceability_info, parse_context, self.custom_tags
237
        )
238
        visitor.visit(robotfw_model)
239
        source_file_traceability_info_processor(
240
            traceability_info, parse_context
241
        )
242
        return traceability_info
243
 
244
    def read_from_file(self, file_path: str) -> SourceFileTraceabilityInfo:
245
        with file_open_read_utf8(file_path) as file:
246
            sdoc_content = file.read()
247
            sdoc = self.read(sdoc_content, file_path=file_path)
248
            return sdoc