[docs]  1# @Test suite for the source order of marked content on one row, TEST_ANA_ORDER_1, test, [IMPL_LNK_1, IMPL_ONE_1]
  2"""Marked content on one row is listed in source order, on every run (#2150).
  3
  4Tree-sitter hands the comments of a file over in an order that differs between two
  5parses of the same source, so two entries on one row used to come out in a different
  6order from run to run. Each construction is analysed ``RUNS`` times in one process: every
  7run must give the same order, and that order must be the source order -- by row, then
  8by column.
  9"""
 10
 11from pathlib import Path
 12from typing import Any
 13
 14import pytest
 15
 16from sphinx_codelinks.analyse.analyse import SourceAnalyse
 17from sphinx_codelinks.config import (
 18    NeedIdRefsConfig,
 19    OneLineCommentStyle,
 20    SourceAnalyseConfig,
 21)
 22from sphinx_codelinks.source_discover.config import CommentType, SourceDiscoverConfig
 23from sphinx_codelinks.source_discover.source_discover import SourceDiscover
 24
 25RUNS = 20
 26ROWS = 15
 27
 28#: the ``[[``...``]]`` style of the corpus case ``positions-need_and_ref_on_one_row``
 29BRACKETS = {
 30    "start_sequence": "[[",
 31    "end_sequence": "]]",
 32    "needs_fields": [
 33        {"name": "id"},
 34        {"name": "title"},
 35        {"name": "type", "default": "impl"},
 36        {"name": "links", "type": "list[str]", "default": []},
 37    ],
 38}
 39
 40
 41def _rows(template: str) -> str:
 42    return "".join(template.format(i=i) + "\n" for i in range(ROWS))
 43
 44
 45#: name -> (source, configuration keywords); every row holds two comments
 46CONSTRUCTIONS: dict[str, tuple[str, dict[str, Any]]] = {
 47    "two_need_refs": (
 48        _rows("/* @need-ids: REQ_A{i} */ /* @need-ids: REQ_B{i} */"),
 49        {"get_need_id_refs": True},
 50    ),
 51    "two_needs": (
 52        _rows("/* @A{i}, IMPL_A{i}, impl */ /* @B{i}, IMPL_B{i}, impl */"),
 53        {"get_oneline_needs": True},
 54    ),
 55    "need_then_ref": (
 56        _rows("/* [[IMPL_X{i}, X]] */ /* @need-ids: REQ_{i} */"),
 57        {
 58            "get_oneline_needs": True,
 59            "get_need_id_refs": True,
 60            "oneline_comment_style": OneLineCommentStyle(**BRACKETS),
 61        },
 62    ),
 63    "ref_then_need": (
 64        _rows("/* @need-ids: REQ_{i} */ /* [[IMPL_X{i}, X]] */"),
 65        {
 66            "get_oneline_needs": True,
 67            "get_need_id_refs": True,
 68            "oneline_comment_style": OneLineCommentStyle(**BRACKETS),
 69        },
 70    ),
 71}
 72
 73
 74def _analyse(root: Path, source: str, **config: Any) -> SourceAnalyse:
 75    path = root / "case.cpp"
 76    path.write_bytes(source.encode("utf-8"))
 77    analyse = SourceAnalyse(
 78        SourceAnalyseConfig(
 79            src_files=[path],
 80            src_dir=root,
 81            comment_type=CommentType.cpp,
 82            **config,
 83        )
 84    )
 85    analyse.git_remote_url = None
 86    analyse.git_commit_rev = None
 87    analyse.run(log_summary=False)
 88    return analyse
 89
 90
 91def _starts(entries: list[Any]) -> list[tuple[int, int]]:
 92    return [
 93        (entry.source_map["start"]["row"], entry.source_map["start"]["column"])
 94        for entry in entries
 95    ]
 96
 97
 98@pytest.mark.parametrize("name", CONSTRUCTIONS)
 99def test_one_row_is_listed_in_source_order_every_run(tmp_path: Path, name: str) -> None:
100    """``all_marked_content`` -- what ``marked_content.json`` holds -- is in source
101    order on every run; so is ``oneline_needs``, the order ``src-trace`` creates the
102    needs in."""
103    source, config = CONSTRUCTIONS[name]
104    runs = [_analyse(tmp_path, source, **config) for _ in range(RUNS)]
105
106    orders = [_starts(run.all_marked_content) for run in runs]
107    assert len(orders[0]) == 2 * ROWS
108    assert orders[0] == sorted(orders[0])
109    assert all(order == orders[0] for order in orders)
110
111    needs = [_starts(run.oneline_needs) for run in runs]
112    assert needs[0] == sorted(needs[0])
113    assert all(order == needs[0] for order in needs)
114
115
116def test_a_reference_and_a_need_starting_at_one_position_list_the_reference_first(
117    tmp_path: Path,
118) -> None:
119    """Two entries can start at one position only with overlapping markers, which the
120    configuration check does not refuse today: here a need-id marker that ends with the
121    one-line start sequence. Such a tie is listed references first, then one-line needs
122    (then multi-line needs) -- the rule ubCode is to mirror (useblocks/ubcode#3935).
123    This case goes when overlapping markers become a configuration error.
124
125    Two references tying (one marker a suffix of another) need no case: they are of one
126    kind, and keep the marker order of the configuration through the stable sort.
127    """
128    analyse = _analyse(
129        tmp_path,
130        "// x ref[[IMPL_T, Tie]]\nvoid f() {}\n",
131        get_oneline_needs=True,
132        get_need_id_refs=True,
133        oneline_comment_style=OneLineCommentStyle(**BRACKETS),
134        need_id_refs_config=NeedIdRefsConfig(markers=["ref[["]),
135    )
136
137    assert [
138        (entry.type.value, entry.source_map["start"])
139        for entry in analyse.all_marked_content
140    ] == [
141        ("need-id-refs", {"row": 0, "column": 10}),
142        ("need", {"row": 0, "column": 10}),
143    ]
144
145
146def test_entries_are_ordered_by_where_they_start_not_where_they_end(
147    tmp_path: Path,
148) -> None:
149    """A one-line need whose title holds a need reference, under the default
150    configuration: both spans run to the end of the line, the need's from further left,
151    so the need is listed first. This pins START order against a sort on the end
152    position with a configuration that overlaps no markers, so it outlives the tie case
153    above. Whether a reference inside a need's title should be extracted at all is a
154    separate question (the overlapping-markers follow-up).
155    """
156    analyse = _analyse(
157        tmp_path,
158        "// @A title @need-ids: REQ_1, IMPL_A, impl\nvoid f() {}\n",
159        get_oneline_needs=True,
160        get_need_id_refs=True,
161    )
162
163    assert [
164        (entry.type.value, entry.source_map["start"])
165        for entry in analyse.all_marked_content
166    ] == [
167        ("need", {"row": 0, "column": 4}),
168        ("need-id-refs", {"row": 0, "column": 23}),
169    ]
170
171
172def test_one_line_needs_of_two_files_on_one_row_keep_the_file_order(
173    tmp_path: Path,
174) -> None:
175    """``oneline_needs`` -- the order ``src-trace`` creates and renders needs in -- is
176    by row, then file (in discovery order), then column: on one row the first file's
177    need comes first even where the other file's sits further left, and a later row of
178    the first file comes after both. ``all_marked_content`` is by file first."""
179    a = tmp_path / "a.cpp"
180    b = tmp_path / "b.cpp"
181    a.write_bytes(b"int x; // @A0, IMPL_A0, impl\n// @A1, IMPL_A1, impl\n")
182    b.write_bytes(b"// @B0, IMPL_B0, impl\n")
183    analyse = SourceAnalyse(
184        SourceAnalyseConfig(
185            src_files=[a, b],
186            src_dir=tmp_path,
187            comment_type=CommentType.cpp,
188            get_oneline_needs=True,
189        )
190    )
191    analyse.git_remote_url = None
192    analyse.git_commit_rev = None
193    analyse.run(log_summary=False)
194
195    assert [need.need["id"] for need in analyse.oneline_needs] == [
196        "IMPL_A0",
197        "IMPL_B0",
198        "IMPL_A1",
199    ]
200    assert [entry.need["id"] for entry in analyse.all_marked_content] == [
201        "IMPL_A0",
202        "IMPL_A1",
203        "IMPL_B0",
204    ]
205
206
207def test_the_file_order_on_one_row_is_the_discovery_order(tmp_path: Path) -> None:
208    """The file part of ``oneline_needs``' key is the one discovery sorts by, not the
209    ``Path``: a ``Path`` compares by parts and would put ``a/b.cpp`` before
210    ``a-c.cpp``, which discovery lists first."""
211    nested = tmp_path / "a" / "b.cpp"
212    nested.parent.mkdir()
213    nested.write_bytes(b"// @Nested, IMPL_NESTED, impl\n")
214    flat = tmp_path / "a-c.cpp"
215    flat.write_bytes(b"// @Flat, IMPL_FLAT, impl\n")
216    src_files = SourceDiscover(
217        SourceDiscoverConfig(src_dir=tmp_path, comment_type="cpp", gitignore=False)
218    ).source_paths
219    assert src_files == [flat.resolve(), nested.resolve()]
220    analyse = SourceAnalyse(
221        SourceAnalyseConfig(
222            src_files=src_files,
223            src_dir=tmp_path,
224            comment_type=CommentType.cpp,
225            get_oneline_needs=True,
226        )
227    )
228    analyse.git_remote_url = None
229    analyse.git_commit_rev = None
230    analyse.run(log_summary=False)
231
232    assert [need.need["id"] for need in analyse.oneline_needs] == [
233        "IMPL_FLAT",
234        "IMPL_NESTED",
235    ]