StrictDoc Documentation
strictdoc/features/source_file_view/generator.py
Source file coverage
Path:
strictdoc/features/source_file_view/generator.py
Lines:
306
Non-empty lines:
281
Non-empty lines covered with requirements:
281 / 281 (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
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
        pygmented_source_file_lines: List[Union[str, SourceMarkerTuple]] = list(
224
            pygmented_source_file_content.split("\n")
225
        )
226
        if hack_first_line:
227
            pygmented_source_file_lines[0] = "<span></span>"
228
 
229
        if pygmented_source_file_lines[-1] == "":
230
            pygmented_source_file_lines.pop()
231
        assert "###" in pygmented_source_file_lines[-1], (
232
            "Expected marker to be in place."
233
        )
234
        # Pop ###, pop "\n"
235
        pygmented_source_file_lines.pop()
236
        if pygmented_source_file_lines[-1] == "":
237
            pygmented_source_file_lines.pop()
238
 
239
        assert len(pygmented_source_file_lines) == len(source_file_lines), (
240
            f"Something went wrong when running Pygments against "
241
            f"the source file: "
242
            f"{len(pygmented_source_file_lines)} == {len(source_file_lines)}, "
243
            f"{pygmented_source_file_lines} == {source_file_lines}."
244
        )
245
 
246
        for marker in coverage_info.markers:
247
            marker_line = (
248
                marker.ng_range_line_begin
249
                if marker.is_begin()
250
                else marker.ng_range_line_end
251
            )
252
            assert isinstance(marker_line, int)
253
            source_marker_tuple: SourceMarkerTuple
254
            if isinstance(
255
                pygmented_source_file_lines[marker_line - 1], SourceMarkerTuple
256
            ):
257
                source_marker_tuple = assert_cast(
258
                    pygmented_source_file_lines[marker_line - 1],
259
                    SourceMarkerTuple,
260
                )
261
            else:
262
                pygmented_source_file_line = assert_cast(
263
                    pygmented_source_file_lines[marker_line - 1], str
264
                )
265
                assert marker.ng_range_line_begin is not None
266
                assert marker.ng_range_line_end is not None
267
                source_marker_tuple = SourceMarkerTuple(
268
                    ng_range_line_begin=marker.ng_range_line_begin,
269
                    ng_range_line_end=marker.ng_range_line_end,
270
                    source_line=Markup(pygmented_source_file_line),
271
                    markers=[],
272
                )
273
                pygmented_source_file_lines[marker_line - 1] = (
274
                    source_marker_tuple
275
                )
276
 
277
            if isinstance(
278
                marker,
279
                (
280
                    ForwardRangeMarker,
281
                    ForwardLanguageItemMarker,
282
                    LanguageItemMarker,
283
                    RangeMarker,
284
                    LineMarker,
285
                ),
286
            ):
287
                source_marker_tuple.markers.append(marker)
288
                continue
289
 
290
            raise NotImplementedError(marker)  # pragma: no cover
291
 
292
        pygments_styles = (
293
            f"/* Lexer: {lexer.name} */\n"
294
            + html_formatter.get_style_defs(".highlight")
295
        )
296
 
297
        return [
298
            SourceFileViewHTMLGenerator.mark_safe(line)
299
            for line in pygmented_source_file_lines
300
        ], Markup(pygments_styles)
301
 
302
    @staticmethod
303
    def mark_safe(
304
        line: Union[str, SourceMarkerTuple],
305
    ) -> Union[Markup, SourceMarkerTuple]:
306
        return line if isinstance(line, SourceMarkerTuple) else Markup(line)