StrictDoc Documentation
strictdoc/backend/sdoc_source_code/reader_rust.py
Source file coverage
Path:
strictdoc/backend/sdoc_source_code/reader_rust.py
Lines:
729
Non-empty lines:
670
Non-empty lines covered with requirements:
670 / 670 (100.0%)
Functions:
21
Functions covered by requirements:
21 / 21 (100.0%)
1
"""
2
@relation(SDOC-SRS-142, scope=file)
3
"""
4
 
5
from enum import IntEnum
6
from functools import lru_cache
7
from pathlib import Path, PurePath
8
from typing import Optional, cast
9
 
10
import toml
11
import tree_sitter_rust as ts_rust
12
from tree_sitter import Language, Node, Parser, Query, QueryCursor
13
 
14
from strictdoc.backend.sdoc_source_code.constants import FunctionAttribute
15
from strictdoc.backend.sdoc_source_code.marker_parser import MarkerParser
16
from strictdoc.backend.sdoc_source_code.models.language import LanguageItem
17
from strictdoc.backend.sdoc_source_code.models.language_item_marker import (
18
    LanguageItemMarker,
19
    RangeMarkerType,
20
)
21
from strictdoc.backend.sdoc_source_code.models.line_marker import LineMarker
22
from strictdoc.backend.sdoc_source_code.models.range_marker import (
23
    RangeMarker,
24
)
25
from strictdoc.backend.sdoc_source_code.models.source_file_info import (
26
    RelationMarkerType,
27
    SourceFileTraceabilityInfo,
28
)
29
from strictdoc.backend.sdoc_source_code.models.source_location import ByteRange
30
from strictdoc.backend.sdoc_source_code.parse_context import ParseContext
31
from strictdoc.backend.sdoc_source_code.processors.general_language_marker_processors import (
32
    language_item_marker_processor,
33
    line_marker_processor,
34
    range_marker_processor,
35
    source_file_traceability_info_processor,
36
)
37
from strictdoc.helpers.cast import assert_cast
38
from strictdoc.helpers.file_stats import SourceFileStats
39
from strictdoc.helpers.file_system import (
40
    file_open_read_bytes,
41
    file_open_read_utf8,
42
)
43
 
44
# @relation(SDOC-LLR-177, SDOC-LLR-171, SDOC-LLR-173, scope=line)
45
TS_QUERY = """
46
; Query 0: Outer doc attribute, line doc, or block doc in allowed positions
47
(
48
  [
49
    (attribute_item
50
      (attribute
51
        (identifier) @_attribute_id (#eq? @_attribute_id "doc")
52
          value: (string_literal (string_content) @doc.comment)))+
53
    (line_comment
54
      outer: (outer_doc_comment_marker)
55
      doc: (doc_comment) @doc.comment)+
56
    (block_comment
57
      outer: (outer_doc_comment_marker)
58
      doc: (doc_comment) @doc.comment)
59
  ]
60
  .
61
  (attribute_item)*
62
  .
63
  [
64
    ; any identifiable item, most notably functions
65
    (_ name: [(identifier)(field_identifier)(type_identifier)(lifetime)] @doc.item_identifier)
66
 
67
    ; impl MyStruct
68
    (impl_item type: (type_identifier) @doc.item_identifier)
69
 
70
    ; extern "C"
71
    (foreign_mod_item (extern_modifier) @doc.item_identifier)
72
 
73
    ; match arm
74
    (match_arm (match_pattern) @doc.item_identifier)
75
 
76
    ; assignment inside struct initializer
77
    (field_initializer field: (field_identifier) @doc.item_identifier)
78
 
79
    ; Statement like 1;
80
    (expression_statement) @doc.item_identifier
81
 
82
    ; Expression like x + y
83
    (binary_expression) @doc.item_identifier
84
 
85
    ; Expression like (x + y)
86
    (parenthesized_expression) @doc.item_identifier
87
 
88
    ; Named "type" field of any enclosing node (usually body), e.g. type within tuple struct.
89
    type: (_)
90
  ] @doc.item
91
)
92
 
93
; Query 1: Inner doc attribute, line doc or block doc in allowed positions.
94
; Note: We have to repeat the identical inner pattern, alternations don't help here.
95
;       See https://github.com/tree-sitter/tree-sitter/issues/3480.
96
[
97
  (function_item
98
    name: (identifier) @doc.item_identifier
99
    body: (block
100
      [
101
        (inner_attribute_item
102
          (attribute
103
            (identifier) @_attribute_id (#eq? @_attribute_id "doc")
104
            value: (string_literal (string_content) @doc.comment)))+
105
        (line_comment
106
          inner: (inner_doc_comment_marker)
107
          doc: (doc_comment) @doc.comment)+
108
        (block_comment
109
          inner: (inner_doc_comment_marker)
110
          doc: (doc_comment) @doc.comment)
111
      ]
112
    )
113
  )
114
  (mod_item
115
    name: (identifier) @doc.item_identifier
116
    body: (declaration_list
117
      [
118
        (inner_attribute_item
119
          (attribute
120
            (identifier) @_attribute_id (#eq? @_attribute_id "doc")
121
            value: (string_literal (string_content) @doc.comment)))+
122
        (line_comment
123
          inner: (inner_doc_comment_marker)
124
          doc: (doc_comment) @doc.comment)+
125
        (block_comment
126
          inner: (inner_doc_comment_marker)
127
          doc: (doc_comment) @doc.comment)
128
      ]
129
    )
130
  )
131
  (impl_item
132
    type: (type_identifier) @doc.item_identifier
133
    body: (declaration_list
134
      [
135
        (inner_attribute_item
136
          (attribute
137
            (identifier) @_attribute_id (#eq? @_attribute_id "doc")
138
            value: (string_literal (string_content) @doc.comment)))+
139
        (line_comment
140
          inner: (inner_doc_comment_marker)
141
          doc: (doc_comment) @doc.comment)+
142
        (block_comment
143
          inner: (inner_doc_comment_marker)
144
          doc: (doc_comment) @doc.comment)
145
      ]
146
    )
147
  )
148
  (foreign_mod_item (extern_modifier) @doc.item_identifier
149
    body: (declaration_list
150
      [
151
        (inner_attribute_item
152
          (attribute
153
            (identifier) @_attribute_id (#eq? @_attribute_id "doc")
154
            value: (string_literal (string_content) @doc.comment)))+
155
        (line_comment
156
          inner: (inner_doc_comment_marker)
157
          doc: (doc_comment) @doc.comment)+
158
        (block_comment
159
          inner: (inner_doc_comment_marker)
160
          doc: (doc_comment) @doc.comment)
161
      ]
162
    )
163
  )
164
] @doc.item
165
 
166
; Query 2: Inner line or block doc comment of file-level module.
167
(source_file
168
  [
169
    (line_comment
170
      inner: (inner_doc_comment_marker)
171
        doc: (doc_comment) @doc.comment)+
172
    (block_comment
173
      inner: (inner_doc_comment_marker)
174
        doc: (doc_comment) @doc.comment)
175
  ]
176
) @doc.item
177
 
178
; Query 3: normal line or block comment
179
[(line_comment !doc)+
180
 (block_comment !doc)] @normal_comment
181
 
182
; Query 4: Identifiable items. Those where it's clear how to link by forward relations.
183
[
184
  (const_item name: (identifier) @doc.item_identifier) @doc.item
185
  (enum_item name: (type_identifier) @doc.item_identifier) @doc.item
186
  (function_item name: (identifier) @doc.item_identifier) @doc.item
187
  (mod_item name: (identifier) @doc.item_identifier) @doc.item
188
  (static_item name: (identifier) @doc.item_identifier) @doc.item
189
  (struct_item name: (type_identifier) @doc.item_identifier) @doc.item
190
  (trait_item name: (type_identifier) @doc.item_identifier) @doc.item
191
  (type_item name: (type_identifier) @doc.item_identifier) @doc.item
192
  (union_item name: (type_identifier) @doc.item_identifier) @doc.item
193
]
194
"""
195
 
196
 
197
class RustTsQuery(IntEnum):
198
    """Give the queries from TS_QUERY a friendly name."""
199
 
200
    OUTER_DOC_COMMENT = 0
201
    INNER_DOC_COMMENT = 1
202
    INNER_DOC_COMMENT_FILEMODULE = 2
203
    NORMAL_COMMENT = 3
204
    IDENTIFIABLE_ITEM = 4
205
 
206
 
207
@lru_cache(maxsize=None)
208
def rust_crate_root_and_name(directory: str) -> Optional[tuple[str, str]]:
209
    """
210
    ``(crate_root, package_name)`` of the nearest ``Cargo.toml`` with a
211
    ``[package]`` at or above ``directory``, or ``None`` if there is none.
212
    ``lru_cache`` memoizes the result per directory, so the many files of one
213
    crate cost a single manifest read.
214
    """
215
    for current in (Path(directory), *Path(directory).parents):
216
        manifest = current / "Cargo.toml"
217
        if not manifest.is_file():
218
            continue
219
        try:
220
            with file_open_read_utf8(str(manifest)) as manifest_file:
221
                package = toml.loads(manifest_file.read()).get("package")
222
        except (OSError, toml.TomlDecodeError):
223
            continue
224
        if isinstance(package, dict):
225
            name = package.get("name")
226
            if isinstance(name, str):
227
                return str(current), name
228
    return None
229
 
230
 
231
def rust_module_segments_within_crate(rel_parts: tuple[str, ...]) -> list[str]:
232
    """
233
    Module path of a source file *within its crate*, from the file's path
234
    relative to the crate root split into ``rel_parts``. Applies Rust's
235
    file<->module convention (the reader sees one file, so it cannot follow
236
    ``mod`` declarations)::
237
 
238
        src/lib.rs | src/main.rs   -> []                 (the crate root)
239
        src/model.rs               -> ["model"]
240
        src/model/mod.rs           -> ["model"]
241
        src/a/b/c.rs               -> ["a", "b", "c"]
242
        tests/it.rs                -> ["it"]             (integration target)
243
        tests/it/helper.rs         -> ["it", "helper"]
244
 
245
    The module path is taken from the file's location, so ``#[path = "..."]``
246
    overrides and non-default ``[lib]`` / ``[[bin]]`` target paths are not
247
    supported.
248
    """
249
 
250
    def module_name(file: str) -> str:
251
        return file[:-3] if file.endswith(".rs") else file
252
 
253
    if not rel_parts:
254
        return []
255
    target_dir, inner = rel_parts[0], list(rel_parts[1:])
256
    if not inner:
257
        stem = module_name(target_dir)
258
        return [] if stem in ("lib", "main") else [stem]
259
    *dirs, leaf = inner
260
    stem = module_name(leaf)
261
    if target_dir == "src" and not dirs and stem in ("lib", "main"):
262
        return []
263
    if stem in ("mod", "main"):
264
        return dirs
265
    return [*dirs, stem]
266
 
267
 
268
def rust_canonical_crate_segments(full_path: Optional[str]) -> list[str]:
269
    """
270
    Canonical-path prefix shared by every item in a Rust file: the crate name
271
    (the ``[package]`` of the nearest ``Cargo.toml``, or the file stem when
272
    there is none) followed by the file's module path within the crate.
273
    """
274
    if not full_path:
275
        return []
276
    path = PurePath(full_path)
277
    crate = rust_crate_root_and_name(str(path.parent))
278
    if crate is None:
279
        return [path.stem]
280
    crate_root, crate_name = crate
281
    try:
282
        rel_parts = path.relative_to(crate_root).parts
283
    except ValueError:
284
        return [path.stem]
285
    return [crate_name, *rust_module_segments_within_crate(rel_parts)]
286
 
287
 
288
def comments_text_from_comment_nodes(comments: list[Node]) -> str:
289
    """
290
    Join multiple comment nodes into one multi-line string.
291
    @relation(SDOC-LLR-175, scope=function)
292
    """
293
    comment_text = assert_cast(comments[0].text, bytes).decode("utf-8")
294
    last_row = comments[0].start_point.row
295
    for comment_part in comments[1:]:
296
        new_lines = comment_part.start_point.row - last_row
297
        last_row = comment_part.start_point.row
298
        comment_text += "\n" * new_lines + assert_cast(
299
            comment_part.text, bytes
300
        ).decode("utf-8")
301
    return comment_text
302
 
303
 
304
def item_definition_line_begin(item: Node) -> int:
305
    """
306
    First 1-based line of ``item``'s definition, including the leading outer
307
    attributes (``#[test]``, ``#[cfg(...)]``) and doc/line comments above it,
308
    which tree-sitter models as siblings rather than part of the item node.
309
    """
310
    line_begin_0_based = item.start_point[0]
311
    sibling = item.prev_sibling
312
    while sibling is not None and sibling.type in (
313
        "attribute_item",
314
        "line_comment",
315
        "block_comment",
316
    ):
317
        # A line comment node extends to the start of the following line, so its
318
        # last line of content is its start row; attributes and block comments
319
        # end where their end point is.
320
        sibling_content_end = (
321
            sibling.start_point[0]
322
            if sibling.type == "line_comment"
323
            else sibling.end_point[0]
324
        )
325
        # Stop once a blank line separates the sibling from the header: such a
326
        # comment documents something else, not this definition.
327
        if sibling_content_end != line_begin_0_based - 1:
328
            break
329
        line_begin_0_based = sibling.start_point[0]
330
        sibling = sibling.prev_sibling
331
    return line_begin_0_based + 1
332
 
333
 
334
def special_description(item: Node, identifier_text: str) -> Optional[str]:
335
    """
336
    Make a description for language constructs that are not functions.
337
 
338
    The default Function description assumes the object actually represents a function.
339
    However, the Rust reader reuses Function to represent many different Rust specific object types.
340
    We have to give them a suitable Rust specific description.
341
    """
342
    if item.type == "associated_type":
343
        return f"associated type {identifier_text}"
344
    elif item.type in ("binary_expression", "parenthesized_expression"):
345
        return f"expression {identifier_text}"
346
    elif item.type == "const_item":
347
        return f"const {identifier_text}"
348
    elif item.type == "const_parameter":
349
        return f"const parameter {identifier_text}"
350
    elif item.type == "function_item":
351
        return f"fn {identifier_text}()"
352
    elif item.type == "enum_item":
353
        return f"enum {identifier_text}"
354
    elif item.type == "enum_variant":
355
        return f"enum variant {identifier_text}"
356
    elif item.type == "expression_statement":
357
        return f"statement {identifier_text}"
358
    elif item.type == "extern_crate_declaration":
359
        return f"crate {identifier_text}"
360
    elif item.type == "field_declaration":
361
        return f"field {identifier_text}"
362
    elif item.type == "field_initializer":
363
        return f"field initializer {identifier_text}"
364
    elif item.type == "foreign_mod_item":
365
        return f"foreign module {identifier_text}"
366
    elif item.type == "impl_item":
367
        return f"impl {identifier_text}"
368
    elif item.type == "match_arm":
369
        return f"match arm {identifier_text}"
370
    elif item.type == "macro_definition":
371
        return f"macro {identifier_text}"
372
    elif item.type == "mod_item":
373
        return f"module {identifier_text}"
374
    elif item.type == "lifetime_parameter":
375
        return f"lifetime {identifier_text}"
376
    elif item.type == "static_item":
377
        return f"static {identifier_text}"
378
    elif item.type == "struct_item":
379
        return f"struct {identifier_text}"
380
    elif item.type == "trait_item":
381
        return f"trait {identifier_text}"
382
    elif item.type == "type_item":
383
        return f"type {identifier_text}"
384
    elif item.type == "type_parameter":
385
        return f"type parameter {identifier_text}"
386
    elif item.type == "union_item":
387
        return f"union {identifier_text}"
388
    elif item.type in ("primitive_type", "type_identifier"):
389
        return f"type {identifier_text}"
390
    return None
391
 
392
 
393
class SourceFileTraceabilityReader_Rust:
394
    @staticmethod
395
    def supported_elements() -> list[str]:
396
        return []
397
 
398
    def __init__(self, custom_tags: Optional[set[str]] = None) -> None:
399
        self.custom_tags: Optional[set[str]] = custom_tags
400
 
401
    def read(
402
        self,
403
        input_buffer: bytes,
404
        file_path: Optional[str] = None,
405
    ) -> SourceFileTraceabilityInfo:
406
        file_stats = SourceFileStats.create(input_buffer)
407
        parse_context = ParseContext(file_path, file_stats)
408
        traceability_info = SourceFileTraceabilityInfo([])
409
        parser = ParserRun(
410
            input_buffer, parse_context, traceability_info, self.custom_tags
411
        )
412
        parser()
413
        source_file_traceability_info_processor(
414
            traceability_info, parse_context
415
        )
416
        return traceability_info
417
 
418
    def read_from_file(self, file_path: str) -> SourceFileTraceabilityInfo:
419
        """
420
        Generate the source file traceability info for one particular Rust file.
421
 
422
        The created SourceFileTraceabilityInfo is filled partially local information:
423
        - functions: Markers are associated, but only those resulting from local markers.
424
        - markers: Markers that stem from markup in this file.
425
        - ng_map_reqs_to_markers: Mapping of requirement IDs to Marker objects for markers directly defined in the source file.
426
        """
427
        with file_open_read_bytes(file_path) as file:
428
            sdoc_content = file.read()
429
            sdoc = self.read(sdoc_content, file_path=file_path)
430
            return sdoc
431
 
432
 
433
class ParserRun:
434
    def __init__(
435
        self,
436
        input_buffer: bytes,
437
        parse_context: ParseContext,
438
        traceability_info: SourceFileTraceabilityInfo,
439
        custom_tags: Optional[set[str]],
440
    ):
441
        rust_language = Language(ts_rust.language())
442
        self.parser = Parser(rust_language)  # type: ignore[call-arg, unused-ignore]
443
        self.TS_QUERY = Query(rust_language, TS_QUERY)
444
        self.input_buffer: bytes = input_buffer
445
        self.parse_context = parse_context
446
        self.traceability_info = traceability_info
447
        self.custom_tags: Optional[set[str]] = custom_tags
448
 
449
    def __call__(self) -> None:
450
        tree = self.parser.parse(self.input_buffer)
451
        cursor = QueryCursor(self.TS_QUERY)
452
        matches = cursor.matches(tree.root_node)
453
 
454
        seen_nodes = set()
455
        deferred_matches = []
456
        for query_index, captures in matches:
457
            if query_index == RustTsQuery.IDENTIFIABLE_ITEM:
458
                # The query for identifiable items overlaps with comment based queries. Move results for identifiable
459
                # items last, so that a result can be skipped if a LanguageItem was already created.
460
                deferred_matches.append(captures)
461
            elif query_index in (
462
                RustTsQuery.OUTER_DOC_COMMENT,
463
                RustTsQuery.INNER_DOC_COMMENT,
464
                RustTsQuery.INNER_DOC_COMMENT_FILEMODULE,
465
            ):
466
                assert len(captures["doc.item"]) == 1
467
                item = captures["doc.item"][0]
468
                doc_comment = captures["doc.comment"]
469
                if (
470
                    item.type == "source_file"
471
                    and "doc.item_identifier" not in captures
472
                ):
473
                    self._process_anonymous_module_comment(
474
                        doc_comment,
475
                        item,
476
                    )
477
                else:
478
                    if "doc.item_identifier" in captures:
479
                        # doc comment on named item
480
                        assert len(captures["doc.item_identifier"]) == 1
481
                        identifier = assert_cast(
482
                            captures["doc.item_identifier"][0].text, bytes
483
                        ).decode()
484
                    else:
485
                        # doc comment on anonymous item
486
                        identifier = assert_cast(item.text, bytes).decode()
487
                    self._process_doc_comment(
488
                        doc_comment,
489
                        item,
490
                        identifier,
491
                    )
492
                seen_nodes.add(item.id)
493
            elif query_index == RustTsQuery.NORMAL_COMMENT:
494
                self._process_normal_comment(captures["normal_comment"])
495
 
496
        for captures in deferred_matches:
497
            assert len(captures["doc.item"]) == 1
498
            assert len(captures["doc.item_identifier"]) == 1
499
            item = captures["doc.item"][0]
500
            if item.id not in seen_nodes:
501
                identifier = assert_cast(
502
                    captures["doc.item_identifier"][0].text, bytes
503
                ).decode()
504
                self._process_item_for_forward_relation(item, identifier)
505
 
506
    def _process_anonymous_module_comment(
507
        self, comments: list[Node], module: Node
508
    ) -> None:
509
        """
510
        Create marker, item and source nodes for file-level module from tree-sitter doc comment nodes.
511
        @relation(SDOC-LLR-164, SDOC-LLR-172, scope=function)
512
        """
513
        comment_text = comments_text_from_comment_nodes(comments)
514
        source_node = MarkerParser.parse(
515
            input_string=comment_text,
516
            line_start=1,
517
            line_end=self.parse_context.file_stats.lines_total,
518
            comment_line_start=module.start_point.row + 1,
519
            comment_byte_range=ByteRange.create_from_ts_nodes(
520
                comments[0], comments[-1]
521
            ),
522
            custom_tags=self.custom_tags,
523
            default_scope="file",
524
        )
525
        for marker_ in source_node.markers:
526
            if not isinstance(marker_, LanguageItemMarker):
527
                continue
528
            # At the top level, only accept the scope=file markers.
529
            # Everything else will be handled by functions and classes.
530
            if marker_.scope != RangeMarkerType.FILE:
531
                print(  # noqa: T201
532
                    "warning: comment to top-level module is not scope=file, ignoring"
533
                )
534
                continue
535
            language_item_marker_processor(marker_, self.parse_context)
536
            self.traceability_info.markers.append(marker_)
537
 
538
    def _process_doc_comment(
539
        self,
540
        comments: list[Node],
541
        item: Node,
542
        identifier_text: str,
543
    ) -> None:
544
        """
545
        Create markers, items and source nodes from tree-sitter doc comment nodes.
546
        @relation(SDOC-LLR-164, SDOC-LLR-172, scope=function)
547
        """
548
        assert len(comments) >= 1
549
        comment_text = comments_text_from_comment_nodes(comments)
550
        line_start_0_based = min(
551
            item.start_point[0], comments[0].start_point[0]
552
        )
553
        line_end_0_based = max(item.end_point[0], comments[-1].end_point[0])
554
        source_node = MarkerParser.parse(
555
            input_string=comment_text,
556
            line_start=line_start_0_based + 1,
557
            line_end=line_end_0_based + 1
558
            if self.input_buffer[-1] == 10
559
            else line_end_0_based,
560
            comment_line_start=comments[0].start_point[0] + 1,
561
            comment_byte_range=ByteRange.create_from_ts_nodes(
562
                comments[0], comments[-1]
563
            ),
564
            custom_tags=self.custom_tags,
565
            entity_name=identifier_text,
566
            default_scope="function",
567
        )
568
 
569
        function_markers: list[LanguageItemMarker] = []
570
        for marker_ in source_node.markers:
571
            if isinstance(marker_, LanguageItemMarker) and (
572
                language_item_marker_ := marker_
573
            ):
574
                if (
575
                    description := special_description(item, identifier_text)
576
                ) is not None:
577
                    language_item_marker_.set_description(description)
578
 
579
                # adds marker to context, and connects context requirements with marker
580
                language_item_marker_processor(
581
                    language_item_marker_, self.parse_context
582
                )
583
                self.traceability_info.markers.append(language_item_marker_)
584
                function_markers.append(marker_)
585
 
586
        name = self.canonical_path(item.parent, identifier_text)
587
        new_function_for_rust_item = LanguageItem(
588
            parent=self.traceability_info,
589
            name=name,
590
            display_name=name,
591
            line_begin=item_definition_line_begin(item),
592
            line_end=item.end_point[0] + 1,
593
            code_byte_range=ByteRange.create_from_ts_node(item),
594
            child_functions=[],
595
            markers=function_markers,
596
            attributes={FunctionAttribute.DEFINITION},
597
        )
598
        if len(source_node.fields) > 0:
599
            source_node.function = new_function_for_rust_item
600
        self.traceability_info.source_nodes.append(source_node)
601
        self.traceability_info.functions.append(new_function_for_rust_item)
602
        # list is invariant, so list[LanguageItemMarker] is not a
603
        # list[RelationMarkerType] even though every element is one. The widen
604
        # is sound; cast it for the map, which holds the wider type.
605
        self.traceability_info.ng_map_names_to_markers[identifier_text] = cast(
606
            list[RelationMarkerType], function_markers
607
        )
608
 
609
    def _process_normal_comment(self, comments: list[Node]) -> None:
610
        """
611
        Create markers and items from tree-sitter normal comment nodes.
612
        @relation(SDOC-LLR-171, scope=function)
613
        """
614
        comment_text = comments_text_from_comment_nodes(comments)
615
        line_start_0_based = comments[0].start_point.row
616
        line_end_0_based = comments[-1].end_point.row
617
        source_node = MarkerParser.parse(
618
            input_string=comment_text,
619
            line_start=line_start_0_based + 1,
620
            line_end=line_end_0_based + 1,
621
            comment_line_start=line_start_0_based + 1,
622
            comment_byte_range=ByteRange.create_from_ts_nodes(
623
                comments[0], comments[-1]
624
            ),
625
        )
626
        for marker_ in source_node.markers:
627
            if (
628
                isinstance(marker_, LanguageItemMarker)
629
                and (marker_.scope is RangeMarkerType.FILE)
630
                and (language_item_marker := marker_)
631
            ):
632
                language_item_marker.ng_range_line_begin = 1
633
                language_item_marker.ng_range_line_end = (
634
                    self.parse_context.file_stats.lines_total
635
                )
636
                language_item_marker_processor(
637
                    language_item_marker, self.parse_context
638
                )
639
            elif isinstance(marker_, RangeMarker) and (range_marker := marker_):
640
                range_marker_processor(range_marker, self.parse_context)
641
            elif isinstance(marker_, LineMarker) and (line_marker := marker_):
642
                line_marker_processor(line_marker, self.parse_context)
643
            else:
644
                print(  # noqa: T201
645
                    "warning: Ignoring @relation. Only scope=file|line|range_start is supported in regular "
646
                    "Rust comments. Use doc comments otherwise."
647
                )
648
 
649
    def _process_item_for_forward_relation(
650
        self, item: Node, identifier: str
651
    ) -> None:
652
        """
653
        Create item objects from tree-sitter doc comment nodes to support forward relations.
654
 
655
        Corresponding markers will be created and resolved later by FileTraceabilityIndex,
656
        see validate_and_resolve.
657
 
658
        @relation(SDOC-LLR-173, scope=function)
659
        """
660
        name = self.canonical_path(item.parent, identifier)
661
        function = LanguageItem(
662
            parent=self.traceability_info,
663
            name=name,
664
            display_name=name,
665
            line_begin=item_definition_line_begin(item),
666
            line_end=max(item.end_point[0] + 1, item.start_point[0] + 2),
667
            code_byte_range=ByteRange.create_from_ts_node(item),
668
            child_functions=[],
669
            markers=[],
670
            attributes={FunctionAttribute.DEFINITION},
671
        )
672
        self.traceability_info.functions.append(function)
673
 
674
    def canonical_path(
675
        self, parent_scope: Optional[Node], item_path_segment: str
676
    ) -> str:
677
        """
678
        Construct a canonical path in best-effort.
679
        @relation(SDOC-LLR-174, scope=function)
680
        """
681
        cursor: Optional[Node] = parent_scope
682
 
683
        if (
684
            cursor is not None
685
            and cursor.type == "declaration_list"
686
            and cursor.parent is not None
687
            and cursor.parent.type == "impl_item"
688
        ):
689
            cursor = cursor.parent
690
            item_being_implemented = cursor.child_by_field_name("type")
691
            assert item_being_implemented is not None
692
            canonical_path_item_being_implemented = self.canonical_path(
693
                cursor,
694
                assert_cast(item_being_implemented.text, bytes).decode("utf-8"),
695
            )
696
            impl_trait_node = cursor.child_by_field_name("trait")
697
            if impl_trait_node is not None:
698
                # rust-lang.org: For trait implementations, [the path prefix] is the canonical path of the item being
699
                # implemented followed by as followed by the canonical path to the trait all surrounded in angle (<>)
700
                # brackets.
701
                trait = self.canonical_path(
702
                    None,
703
                    assert_cast(impl_trait_node.text, bytes).decode("utf-8"),
704
                )
705
                path_prefix = (
706
                    f"<{canonical_path_item_being_implemented} as {trait}>"
707
                )
708
            else:
709
                # rust-lang.org: For bare implementations, [the path prefix] is the canonical path of the item being
710
                # implemented surrounded by angle (<>) brackets.
711
                path_prefix = f"<{canonical_path_item_being_implemented}>"
712
        else:
713
            path_prefix_segments = []
714
            while cursor is not None:
715
                name_node = cursor.child_by_field_name("name")
716
                if name_node is not None:
717
                    name = assert_cast(name_node.text, bytes).decode("utf-8")
718
                    path_prefix_segments.append(name)
719
                cursor = cursor.parent
720
            path_prefix_segments.extend(
721
                reversed(
722
                    rust_canonical_crate_segments(self.parse_context.filename)
723
                )
724
            )
725
            path_prefix = "::".join(reversed(path_prefix_segments))
726
 
727
        # rust-lang.org: The canonical path is defined as a path prefix appended by the path segment the item itself
728
        # defines.
729
        return f"{path_prefix}::{item_path_segment}"