StrictDoc Documentation
strictdoc/features/source_file_view/generator.py
Source file coverage
Path:
strictdoc/features/source_file_view/generator.py
Lines:
313
Non-empty lines:
288
Non-empty lines covered with requirements:
288 / 288 (100.0%)
Functions:
5
Functions covered by requirements:
5 / 5 (100.0%)
1
"""
2
@relation(SDOC-SRS-36, scope=file)
3
"""
4
 
5
# mypy: disable-error-code="operator"
6
from pathlib import Path
7
from typing import List, Tuple, Union, cast
8
 
9
from markupsafe import Markup
10
from pygments import highlight
11
from pygments.formatters.html import HtmlFormatter
12
from pygments.lexers import get_lexer_for_filename
13
from pygments.lexers.c_cpp import CLexer, CppLexer
14
from pygments.lexers.configs import TOMLLexer
15
from pygments.lexers.data import YamlLexer
16
from pygments.lexers.javascript import JavascriptLexer
17
from pygments.lexers.markup import RstLexer, TexLexer
18
from pygments.lexers.python import PythonLexer
19
from pygments.lexers.special import TextLexer
20
from pygments.lexers.templates import HtmlDjangoLexer
21
from pygments.util import ClassNotFound
22
 
23
from strictdoc.backend.sdoc.constants import SDocMarkup
24
from strictdoc.backend.sdoc_source_code.models.language_item_marker import (
25
    ForwardLanguageItemMarker,
26
    LanguageItemMarker,
27
)
28
from strictdoc.backend.sdoc_source_code.models.line_marker import LineMarker
29
from strictdoc.backend.sdoc_source_code.models.range_marker import (
30
    ForwardRangeMarker,
31
    RangeMarker,
32
)
33
from strictdoc.backend.sdoc_source_code.models.source_file_info import (
34
    SourceFileTraceabilityInfo,
35
)
36
from strictdoc.core.file_system.source_tree import SourceFile
37
from strictdoc.core.project_config import ProjectConfig
38
from strictdoc.core.traceability_index import TraceabilityIndex
39
from strictdoc.export.html.html_templates import HTMLTemplates
40
from strictdoc.export.html.renderers.link_renderer import LinkRenderer
41
from strictdoc.export.html.renderers.markup_renderer import MarkupRenderer
42
from strictdoc.features.source_file_view.view_object import (
43
    SourceFileViewObject,
44
    SourceLineEntry,
45
    SourceMarkerTuple,
46
)
47
from strictdoc.helpers.cast import assert_cast
48
from strictdoc.helpers.file_system import file_open_read_utf8
49
from strictdoc.helpers.timing import measure_performance
50
 
51
 
52
class SourceFileViewHTMLGenerator:
53
    @staticmethod
54
    def export_to_file(
55
        *,
56
        project_config: ProjectConfig,
57
        source_file: SourceFile,
58
        traceability_index: TraceabilityIndex,
59
        html_templates: HTMLTemplates,
60
    ) -> None:
61
        if not traceability_index.file_dependency_manager.must_generate(
62
            source_file.output_file_full_path
63
        ):
64
            with measure_performance(
65
                f"Skip: {source_file.in_doctree_source_file_rel_path}"
66
            ):
67
                return
68
 
69
        with measure_performance(
70
            f"File: {source_file.in_doctree_source_file_rel_path}"
71
        ):
72
            document_content = SourceFileViewHTMLGenerator.export(
73
                project_config=project_config,
74
                source_file=source_file,
75
                traceability_index=traceability_index,
76
                html_templates=html_templates,
77
            )
78
            Path(source_file.output_dir_full_path).mkdir(
79
                parents=True, exist_ok=True
80
            )
81
            with open(
82
                source_file.output_file_full_path, "w", encoding="utf-8"
83
            ) as file:
84
                file.write(document_content)
85
 
86
    @staticmethod
87
    def export(
88
        *,
89
        project_config: ProjectConfig,
90
        source_file: SourceFile,
91
        traceability_index: TraceabilityIndex,
92
        html_templates: HTMLTemplates,
93
    ) -> Markup:
94
        with file_open_read_utf8(source_file.full_path) as opened_file:
95
            source_file_lines = opened_file.readlines()
96
 
97
        pygmented_source_file_lines: List[SourceLineEntry] = []
98
        pygments_styles: Markup = Markup("")
99
 
100
        trace_info: SourceFileTraceabilityInfo = (
101
            traceability_index.get_coverage_info(
102
                source_file.in_doctree_source_file_rel_path_posix
103
            )
104
        )
105
 
106
        if len(source_file_lines) > 0:
107
            (
108
                pygmented_source_file_lines,
109
                pygments_styles,
110
            ) = SourceFileViewHTMLGenerator.get_pygmented_source_lines(
111
                source_file, source_file_lines, trace_info
112
            )
113
        link_renderer = LinkRenderer(
114
            root_path=source_file.path_depth_prefix,
115
            static_path=project_config.dir_for_sdoc_assets,
116
        )
117
        markup_renderer = MarkupRenderer.create(
118
            SDocMarkup.RST,
119
            traceability_index,
120
            link_renderer,
121
            html_templates,
122
            project_config,
123
            None,
124
        )
125
        text_renderer = MarkupRenderer.create(
126
            SDocMarkup.TEXT,
127
            traceability_index,
128
            link_renderer,
129
            html_templates,
130
            project_config,
131
            None,
132
        )
133
        view_object = SourceFileViewObject(
134
            traceability_index=traceability_index,
135
            trace_info=trace_info,
136
            project_config=project_config,
137
            link_renderer=link_renderer,
138
            markup_renderer=markup_renderer,
139
            text_renderer=text_renderer,
140
            source_file=source_file,
141
            pygments_styles=pygments_styles,
142
            pygmented_source_file_lines=pygmented_source_file_lines,
143
            jinja_environment=html_templates.jinja_environment(),
144
        )
145
        return view_object.render_screen()
146
 
147
    @staticmethod
148
    def get_pygmented_source_lines(
149
        source_file: SourceFile,
150
        source_file_lines: List[str],
151
        coverage_info: SourceFileTraceabilityInfo,
152
    ) -> Tuple[
153
        List[SourceLineEntry],
154
        Markup,
155
    ]:
156
        assert isinstance(source_file, SourceFile)
157
        assert isinstance(source_file_lines, list)
158
        assert isinstance(coverage_info, SourceFileTraceabilityInfo)
159
 
160
        if source_file.is_python_file():
161
            lexer = PythonLexer()
162
        elif source_file.is_c_file():
163
            lexer = CLexer()
164
        elif source_file.is_cpp_file():
165
            lexer = CppLexer()
166
        elif source_file.is_tex_file():
167
            lexer = TexLexer()
168
        elif source_file.is_toml_file():
169
            lexer = TOMLLexer()
170
        elif source_file.is_jinja_file():
171
            lexer = HtmlDjangoLexer()
172
        elif source_file.is_javascript_file():
173
            lexer = JavascriptLexer()
174
        elif source_file.is_yaml_file():
175
            lexer = YamlLexer()
176
        elif source_file.is_rst_file():
177
            lexer = RstLexer()
178
        elif source_file.is_st_file():
179
            # Structured Text syntax doesn't seem to be supported by Pygments.
180
            lexer = TextLexer()
181
        else:
182
            try:
183
                lexer = get_lexer_for_filename(source_file.file_name)
184
            except ClassNotFound:
185
                lexer = TextLexer()
186
 
187
        # HACK:
188
        # Otherwise, Pygments will skip the first line as if it does not exist.
189
        # This behavior surprisingly has an effect on the first line if its empty.
190
        hack_first_line: bool = False
191
        if source_file_lines[0] == "\n":
192
            source_file_lines[0] = " \n"
193
            hack_first_line = True
194
 
195
        # HACK:
196
        # Pygments does not process lines if they are empty and are at the end
197
        # of a file. Adding a marker to the end so that Pygments do not cut the
198
        # corners.
199
        source_file_content = "".join(source_file_lines)
200
        source_file_content_with_marker = source_file_content + "\n###"
201
 
202
        html_formatter = HtmlFormatter()
203
        pygmented_source_file_content = highlight(
204
            source_file_content_with_marker, lexer, html_formatter
205
        )
206
 
207
        # HACK: split content into lines by cutting off the header and footer
208
        # parts generated by Pygments:
209
        # <div class="highlight"><pre> and </pre></div>
210
        # TODO: Implement proper splitting.
211
        start_pattern = '<div class="highlight"><pre>'
212
        end_pattern = "</pre></div>\n"
213
        assert pygmented_source_file_content.startswith(start_pattern)
214
        assert pygmented_source_file_content.endswith(end_pattern), (
215
            f"{pygmented_source_file_content}"
216
        )
217
 
218
        slice_start = len(start_pattern)
219
        slice_end = len(pygmented_source_file_content) - len(end_pattern)
220
        pygmented_source_file_content = pygmented_source_file_content[
221
            slice_start:slice_end
222
        ]
223
        # cast() is required for mypy: split() returns List[str], and
224
        # because List is invariant, mypy rejects assigning it directly to
225
        # a List[Union[str, SourceMarkerTuple]] variable even with the
226
        # annotation in place. cast() is a no-op at runtime, unlike
227
        # wrapping in list(), which would silently allocate a second,
228
        # unnecessary copy of the list just to satisfy the type checker.
229
        pygmented_source_file_lines: List[Union[str, SourceMarkerTuple]] = cast(
230
            List[Union[str, SourceMarkerTuple]],
231
            pygmented_source_file_content.split("\n"),
232
        )
233
        if hack_first_line:
234
            pygmented_source_file_lines[0] = "<span></span>"
235
 
236
        if pygmented_source_file_lines[-1] == "":
237
            pygmented_source_file_lines.pop()
238
        assert "###" in pygmented_source_file_lines[-1], (
239
            "Expected marker to be in place."
240
        )
241
        # Pop ###, pop "\n"
242
        pygmented_source_file_lines.pop()
243
        if pygmented_source_file_lines[-1] == "":
244
            pygmented_source_file_lines.pop()
245
 
246
        assert len(pygmented_source_file_lines) == len(source_file_lines), (
247
            f"Something went wrong when running Pygments against "
248
            f"the source file: "
249
            f"{len(pygmented_source_file_lines)} == {len(source_file_lines)}, "
250
            f"{pygmented_source_file_lines} == {source_file_lines}."
251
        )
252
 
253
        for marker in coverage_info.markers:
254
            marker_line = (
255
                marker.ng_range_line_begin
256
                if marker.is_begin()
257
                else marker.ng_range_line_end
258
            )
259
            assert isinstance(marker_line, int)
260
            source_marker_tuple: SourceMarkerTuple
261
            if isinstance(
262
                pygmented_source_file_lines[marker_line - 1], SourceMarkerTuple
263
            ):
264
                source_marker_tuple = assert_cast(
265
                    pygmented_source_file_lines[marker_line - 1],
266
                    SourceMarkerTuple,
267
                )
268
            else:
269
                pygmented_source_file_line = assert_cast(
270
                    pygmented_source_file_lines[marker_line - 1], str
271
                )
272
                assert marker.ng_range_line_begin is not None
273
                assert marker.ng_range_line_end is not None
274
                source_marker_tuple = SourceMarkerTuple(
275
                    ng_range_line_begin=marker.ng_range_line_begin,
276
                    ng_range_line_end=marker.ng_range_line_end,
277
                    source_line=Markup(pygmented_source_file_line),
278
                    markers=[],
279
                )
280
                pygmented_source_file_lines[marker_line - 1] = (
281
                    source_marker_tuple
282
                )
283
 
284
            if isinstance(
285
                marker,
286
                (
287
                    ForwardRangeMarker,
288
                    ForwardLanguageItemMarker,
289
                    LanguageItemMarker,
290
                    RangeMarker,
291
                    LineMarker,
292
                ),
293
            ):
294
                source_marker_tuple.markers.append(marker)
295
                continue
296
 
297
            raise NotImplementedError(marker)  # pragma: no cover
298
 
299
        pygments_styles = (
300
            f"/* Lexer: {lexer.name} */\n"
301
            + html_formatter.get_style_defs(".highlight")
302
        )
303
 
304
        return [
305
            SourceFileViewHTMLGenerator.mark_safe(line)
306
            for line in pygmented_source_file_lines
307
        ], Markup(pygments_styles)
308
 
309
    @staticmethod
310
    def mark_safe(
311
        line: Union[str, SourceMarkerTuple],
312
    ) -> Union[Markup, SourceMarkerTuple]:
313
        return line if isinstance(line, SourceMarkerTuple) else Markup(line)