[docs]  1# @Test suite for multi-line need runs and prefixes and grammar, TEST_MLN_1, test, [IMPL_MLN_1, IMPL_MLN_2]
  2"""Unit tests of ``analyse/multiline_parser.py``: run formation, prefix stripping by
  3comment kind, and the block grammar on logical lines.
  4
  5The comment nodes here are synthetic: the module reads only ``.text``, ``.type``,
  6``.start_point.row``, ``.start_point.column`` and ``is_libclang``.
  7"""
  8
  9from __future__ import annotations
 10
 11from pathlib import Path
 12from types import SimpleNamespace
 13
 14import pytest
 15
 16from sphinx_codelinks.analyse.analyse import SourceAnalyse
 17from sphinx_codelinks.analyse.models import SourceComment, WarningSubTypeEnum
 18from sphinx_codelinks.analyse.multiline_parser import (
 19    LogicalLine,
 20    blank_rows,
 21    form_runs,
 22    open_rest,
 23    parse_run,
 24)
 25from sphinx_codelinks.config import (
 26    MultilineNeedsConfig,
 27    PreprocessorConfig,
 28    SourceAnalyseConfig,
 29)
 30from sphinx_codelinks.source_discover.config import CommentType
 31
 32
 33def _node(text: str, row: int, column: int = 0, kind: str = "comment") -> SourceComment:
 34    node = SimpleNamespace(
 35        text=text.encode("utf-8"),
 36        type=kind,
 37        start_point=SimpleNamespace(row=row, column=column),
 38    )
 39    return SourceComment(node)  # ty: ignore[invalid-argument-type]
 40
 41
 42def _libclang(text: str, row: int) -> SourceComment:
 43    node = SimpleNamespace(
 44        text=text.encode("utf-8"),
 45        start_point=SimpleNamespace(row=row),
 46        is_libclang=True,
 47    )
 48    return SourceComment(node)  # ty: ignore[invalid-argument-type]
 49
 50
 51def _texts(run) -> list[str]:
 52    return [line.text for line in run.lines]
 53
 54
 55def _lines(*texts: str, first_row: int = 0) -> list[LogicalLine]:
 56    return [LogicalLine(first_row + i, 0, text) for i, text in enumerate(texts)]
 57
 58
 59CONFIG = MultilineNeedsConfig()
 60
 61
 62# --- run formation ----------------------------------------------------------------------
 63
 64
 65def test_consecutive_line_comments_with_one_delimiter_form_one_run() -> None:
 66    runs = form_runs([_node("// a", 0), _node("// b", 1), _node("// c", 2)])
 67
 68    assert [_texts(run) for run in runs] == [["a", "b", "c"]]
 69
 70
 71def test_a_gap_of_one_row_splits_a_run() -> None:
 72    runs = form_runs([_node("// a", 0), _node("// b", 2)])
 73
 74    assert [_texts(run) for run in runs] == [["a"], ["b"]]
 75
 76
 77def test_a_change_of_delimiter_splits_a_run() -> None:
 78    runs = form_runs([_node("/// a", 0), _node("// b", 1), _node("//! c", 2)])
 79
 80    assert [_texts(run) for run in runs] == [["a"], ["b"], ["c"]]
 81
 82
 83def test_a_comment_after_code_neither_starts_nor_continues_a_run() -> None:
 84    source = ["int x; // a", "// b", "// c", "f(); // d"]
 85    comments = [
 86        _node("// a", 0, 7),
 87        _node("// b", 1),
 88        _node("// c", 2),
 89        _node("// d", 3, 5),
 90    ]
 91
 92    runs = form_runs(comments, source)
 93
 94    assert [_texts(run) for run in runs] == [["a"], ["b", "c"], ["d"]]
 95
 96
 97def test_the_comments_are_read_in_source_order() -> None:
 98    """tree-sitter's captures are not in source order."""
 99    runs = form_runs([_node("// c", 2), _node("// a", 0), _node("// b", 1)])
100
101    assert [_texts(run) for run in runs] == [["a", "b", "c"]]
102
103
104def test_a_block_comment_is_a_run_of_its_own() -> None:
105    runs = form_runs([_node("// a", 0), _node("/* b */", 1), _node("// c", 2)])
106
107    assert [_texts(run) for run in runs] == [["a"], [" b "], ["c"]]
108
109
110def test_a_powershell_block_comment_is_a_run_of_its_own() -> None:
111    runs = form_runs([_node("# a", 0), _node("<# b #>", 1), _node("# c", 2)])
112
113    assert [_texts(run) for run in runs] == [["a"], [" b "], ["c"]]
114
115
116def test_libclang_comments_form_runs_from_their_rows() -> None:
117    """A libclang comment has no column: what precedes it is read from its row."""
118    source = ["// a", "// b", "int x; // c", "// d"]
119    comments = [
120        _libclang("// a", 0),
121        _libclang("// b", 1),
122        _libclang("// c", 2),
123        _libclang("// d", 3),
124    ]
125
126    runs = form_runs(comments, source)
127
128    assert [_texts(run) for run in runs] == [["a", "b"], ["c"], ["d"]]
129    assert runs[1].lines[0].col == len("int x; // ")
130
131
132def test_libclang_comments_without_their_rows_count_as_alone() -> None:
133    runs = form_runs([_libclang("// a", 0), _libclang("// b", 1)])
134
135    assert [_texts(run) for run in runs] == [["a", "b"]]
136
137
138# --- prefix stripping -------------------------------------------------------------------
139
140
141def test_a_line_comment_loses_its_delimiter_and_one_space() -> None:
142    (run,) = form_runs([_node("//   indented", 3, 4)], ["    //   indented"])
143
144    assert run.lines == [LogicalLine(3, 7, "  indented")]
145
146
147def test_a_powershell_block_comment_loses_its_delimiter_rows() -> None:
148    text = "<#\n@need impl: T\n:id: IMPL_B\n@endneed\n#>"
149
150    (run,) = form_runs([_node(text, 0)])
151
152    assert _texts(run) == ["@need impl: T", ":id: IMPL_B", "@endneed"]
153    assert [line.row for line in run.lines] == [1, 2, 3]
154
155
156def test_a_rust_doc_comment_loses_its_trailing_newline() -> None:
157    (run,) = form_runs([_node("/// a\n", 0, kind="line_comment")])
158
159    assert _texts(run) == ["a"]
160
161
162def test_the_doxygen_leader_is_stripped_when_every_line_has_one() -> None:
163    text = "/**\n * @need req: T\n *\n * Some *emphasis* here.\n * * item\n */"
164
165    (run,) = form_runs([_node(text, 0)])
166
167    assert _texts(run) == ["@need req: T", "", "Some *emphasis* here.", "* item"]
168    assert [line.row for line in run.lines] == [1, 2, 3, 4]
169    assert run.lines[0].col == len(" * ")
170
171
172@pytest.mark.parametrize(
173    "text",
174    [
175        pytest.param(
176            "/**\n * @need req: T\n * @endneed\n ***/", id="stars_before_the_closer"
177        ),
178        pytest.param(
179            "/*********\n * @need req: T\n * @endneed\n *********/", id="full_banner"
180        ),
181    ],
182)
183def test_delimiter_only_banner_rows_are_dropped_before_the_leader_test(
184    text: str,
185) -> None:
186    """A row holding only the opener's or the closer's stars is a delimiter, not a line
187    without a leader."""
188    (run,) = form_runs([_node(text, 0)])
189
190    assert _texts(run) == ["@need req: T", "@endneed"]
191    assert [line.row for line in run.lines] == [1, 2]
192
193
194def test_a_qt_opener_is_stripped_like_a_doxygen_one() -> None:
195    (run,) = form_runs([_node("/*! @need req: T\n * @endneed\n */", 0)])
196    (alone,) = form_runs([_node("/*!\n * @need req: T\n * @endneed\n */", 5)])
197
198    assert _texts(run) == [" @need req: T", "@endneed"]
199    (block,) = parse_run(run.lines, CONFIG).blocks
200    assert (block.open_row, block.open_col) == (0, len("/*! "))
201    assert _texts(alone) == ["@need req: T", "@endneed"]
202
203
204def test_no_leader_is_stripped_when_one_line_lacks_it() -> None:
205    """The every-line rule: a star that is not a leader on every line is content."""
206    text = "/*\n * @need req: T\n   Some *emphasis* here.\n */"
207
208    (run,) = form_runs([_node(text, 0)])
209
210    assert _texts(run) == [" * @need req: T", "   Some *emphasis* here."]
211    assert run.leaderless_block
212
213
214def test_an_open_word_behind_a_leader_in_a_leaderless_block_is_refused() -> None:
215    """Not silently lost: the header refusal names the cause, and consumes its lines up
216    to the first close line, which may carry the stars too."""
217    text = "/*\n * @need req: T\n   no leader\n * @param a, b\n * @endneed\n */"
218    (run,) = form_runs([_node(text, 0)])
219
220    result = parse_run(run.lines, CONFIG, leaderless_block=run.leaderless_block)
221
222    assert result.blocks == []
223    assert [(issue.kind, issue.row) for issue in result.issues] == [
224        (WarningSubTypeEnum.multiline_need_header, 1)
225    ]
226    assert "behind a '*' leader" in result.issues[0].msg
227    assert result.claimed_rows == {1, 2, 3, 4}
228    assert parse_run(run.lines, CONFIG).issues == [], "only for a leaderless block"
229
230
231@pytest.mark.parametrize(
232    "text",
233    [
234        pytest.param("/*\n * @need req: T\n   no leader\n */", id="starred_open"),
235        pytest.param(
236            "/** @need req: T\n   no leader\n * @endneed\n */", id="opener_row"
237        ),
238    ],
239)
240def test_an_unterminated_open_in_a_leaderless_block_names_the_leader(text: str) -> None:
241    (run,) = form_runs([_node(text, 0)])
242
243    assert run.mixed_leaders
244    result = parse_run(
245        run.lines,
246        CONFIG,
247        leaderless_block=run.leaderless_block,
248        mixed_leaders=run.mixed_leaders,
249    )
250
251    (issue,) = result.issues
252    assert issue.kind == WarningSubTypeEnum.multiline_need_unterminated
253    assert "'*' leader" in issue.msg
254    assert result.claimed_rows == set()
255
256
257def test_a_stars_only_line_counts_as_a_leader_line() -> None:
258    """A separator of stars inside a leader block is a blank line, not a line without a
259    leader; behind a leader, stars are body text."""
260    text = "/**\n * @need req: T\n *\n * Body.\n *********\n * ****\n * @endneed\n */"
261
262    (run,) = form_runs([_node(text, 0)])
263
264    assert not run.leaderless_block
265    assert _texts(run) == ["@need req: T", "", "Body.", "", "****", "@endneed"]
266
267
268def test_an_emphasis_line_is_not_a_leader() -> None:
269    text = "/*\n*emphasis* first\n*more* second\n*/"
270
271    (run,) = form_runs([_node(text, 0)])
272
273    assert _texts(run) == ["*emphasis* first", "*more* second"]
274
275
276def test_a_one_row_block_comment_keeps_its_text() -> None:
277    (run,) = form_runs([_node("/* @need req: T @endneed */", 0, 2)])
278
279    assert run.lines == [LogicalLine(0, 4, " @need req: T @endneed ")]
280
281
282def test_a_docstring_is_unquoted_and_dedented_like_cleandoc() -> None:
283    text = 'r"""First line.\n\n    @need impl: T\n      deeper\n    """'
284
285    (run,) = form_runs([_node(text, 5, 4, kind="expression_statement")])
286
287    assert _texts(run) == ["First line.", "", "@need impl: T", "  deeper", ""]
288    assert run.lines[0].col == 4 + len('r"""')
289    assert run.lines[2] == LogicalLine(7, 4, "@need impl: T")
290
291
292def test_a_comment_without_a_delimiter_is_read_unstripped() -> None:
293    (run,) = form_runs([_libclang("@need req: T", 0)])
294
295    assert _texts(run) == ["@need req: T"]
296
297
298# --- the grammar ------------------------------------------------------------------------
299
300
301def test_a_block_with_options_and_a_body() -> None:
302    lines = [
303        LogicalLine(1, 3, "@need[md] req: A title"),
304        LogicalLine(2, 3, ":id: REQ_1"),
305        LogicalLine(3, 3, ":links: A, B"),
306        LogicalLine(4, 3, ""),
307        LogicalLine(5, 3, "  Body *text*."),
308        LogicalLine(6, 3, "    deeper"),
309        LogicalLine(7, 3, ""),
310        LogicalLine(8, 3, "@endneed"),
311    ]
312
313    result = parse_run(lines, CONFIG)
314
315    (block,) = result.blocks
316    assert block.need == {
317        "type": "req",
318        "title": "A title",
319        "id": "REQ_1",
320        "links": "A, B",
321        "content": "Body *text*.\n  deeper",
322        "doctype": ".md",
323    }
324    assert block.markup == "md"
325    assert (block.open_row, block.open_col) == (1, 3)
326    assert (block.close_row, block.end_col) == (8, 3 + len("@endneed"))
327    assert block.option_rows == {"id": 3, "links": 4}
328    assert block.content_start == (5, 5)
329    assert result.issues == []
330    assert result.claimed_rows == set(range(1, 9))
331
332
333def test_the_open_word_must_be_a_whole_word() -> None:
334    assert open_rest("@need req: T", "@need") == " req: T"
335    assert open_rest("  @need[md] req: T", "@need") == "[md] req: T"
336    assert open_rest("@need", "@need") == ""
337    assert open_rest("@need-ids: REQ_1", "@need") is None
338    assert open_rest("@needle", "@need") is None
339    assert open_rest("see @need req: T", "@need") is None
340
341
342def test_a_reference_line_is_not_an_open_line() -> None:
343    result = parse_run(_lines("@need-ids: REQ_1"), CONFIG)
344
345    assert (result.blocks, result.issues, result.claimed_rows) == ([], [], set())
346
347
348@pytest.mark.parametrize(
349    "open_line",
350    [
351        "@need",
352        "@need req",
353        "@need req:Title",
354        "@need [md] req: T",
355        "@need this: or that",
356    ],
357)
358def test_a_malformed_open_line_is_refused_and_consumed(open_line: str) -> None:
359    """The last one is no exception: its ``this`` is a type and ``or that`` the title.
360    A line is refused only when no ``<type>:`` follows the open word."""
361    result = parse_run(_lines(open_line, "@param a, b", "@endneed"), CONFIG)
362
363    if open_line == "@need this: or that":
364        assert [block.need["type"] for block in result.blocks] == ["this"]
365    else:
366        assert result.blocks == []
367        assert [(issue.kind, issue.row) for issue in result.issues] == [
368            (WarningSubTypeEnum.multiline_need_header, 0)
369        ]
370    assert result.claimed_rows == {0, 1, 2}
371
372
373def test_an_unterminated_block_is_refused_and_ends_the_scan() -> None:
374    result = parse_run(_lines("@need req: A", "@need req: B", "body"), CONFIG)
375
376    assert result.blocks == []
377    assert [(issue.kind, issue.row) for issue in result.issues] == [
378        (WarningSubTypeEnum.multiline_need_unterminated, 0)
379    ]
380    assert result.claimed_rows == set()
381
382
383def test_the_one_line_form_is_refused_and_claims_its_own_row() -> None:
384    """Its row is hidden from the one-line parser, which would otherwise mint a need
385    from a title holding a comma, or warn a second time."""
386    result = parse_run(_lines("@need req: T, with comma @endneed", "next"), CONFIG)
387
388    assert [(issue.kind, issue.row) for issue in result.issues] == [
389        (WarningSubTypeEnum.multiline_need_oneline_form, 0)
390    ]
391    assert result.claimed_rows == {0}
392
393
394def test_the_one_line_form_needs_the_close_word_not_a_suffix() -> None:
395    result = parse_run(_lines("@need req: Mentions foo@endneed"), CONFIG)
396
397    assert [issue.kind for issue in result.issues] == [
398        WarningSubTypeEnum.multiline_need_unterminated
399    ]
400
401
402def test_an_unknown_markup_falls_back_to_the_default() -> None:
403    result = parse_run(_lines("@need[xyz] req: T", "@endneed"), CONFIG)
404
405    (block,) = result.blocks
406    assert (block.markup, block.need["doctype"]) == ("rst", ".rst")
407    assert [issue.kind for issue in result.issues] == [
408        WarningSubTypeEnum.multiline_need_markup
409    ]
410
411
412def test_a_continuation_line_joins_its_option_with_one_space() -> None:
413    lines = _lines(
414        "@need req: T", ":links:", "  A,", "  B", ":status: open", "@endneed"
415    )
416
417    (block,) = parse_run(lines, CONFIG).blocks
418
419    assert block.need["links"] == "A, B"
420    assert block.need["status"] == "open"
421
422
423def test_a_duplicate_option_keeps_the_first_value_and_drops_its_continuation() -> None:
424    lines = _lines("@need req: T", ":id: A", ":id: B", "   more", "@endneed")
425
426    result = parse_run(lines, CONFIG)
427
428    (block,) = result.blocks
429    assert block.need["id"] == "A"
430    assert block.need["content"] == ""
431    assert [(issue.kind, issue.row) for issue in result.issues] == [
432        (WarningSubTypeEnum.multiline_need_duplicate_option, 2)
433    ]
434
435
436@pytest.mark.parametrize("key", ["type", "title", "content", "doctype"])
437def test_an_option_naming_a_record_key_is_a_duplicate(key: str) -> None:
438    lines = _lines("@need req: T", f":{key}: other", "", "Body.", "@endneed")
439
440    result = parse_run(lines, CONFIG)
441
442    (block,) = result.blocks
443    assert (block.need["type"], block.need["title"]) == ("req", "T")
444    assert (block.need["content"], block.need["doctype"]) == ("Body.", ".rst")
445    assert [issue.kind for issue in result.issues] == [
446        WarningSubTypeEnum.multiline_need_duplicate_option
447    ]
448
449
450def test_the_first_line_that_is_not_an_option_starts_the_body() -> None:
451    """No blank line is needed; a blank line ends the options too."""
452    (block,) = parse_run(
453        _lines("@need req: T", ":id: A", "Body.", ":not: an option", "@endneed"),
454        CONFIG,
455    ).blocks
456
457    assert block.need == {
458        "type": "req",
459        "title": "T",
460        "id": "A",
461        "content": "Body.\n:not: an option",
462        "doctype": ".rst",
463    }
464
465
466def test_an_escaped_close_is_body_text_without_its_backslash() -> None:
467    lines = _lines("@need req: T", "", "\\@endneed", "\\@need stays", "@endneed")
468
469    (block,) = parse_run(lines, CONFIG).blocks
470
471    assert block.need["content"] == "@endneed\n\\@need stays"
472
473
474def test_an_indented_escape_loses_its_backslash_and_keeps_its_indentation() -> None:
475    """The escape mirrors the close test, which ignores indentation: so the close word
476    can be shown inside an indented block of the body."""
477    lines = _lines("@need req: T", "", "Example::", "", "    \\@endneed", "@endneed")
478
479    (block,) = parse_run(lines, CONFIG).blocks
480
481    assert block.need["content"] == "Example::\n\n    @endneed"
482
483
484def test_a_nested_open_is_body_text_and_noted() -> None:
485    lines = _lines("@need req: T", "", "@need req: inner", "@endneed")
486
487    result = parse_run(lines, CONFIG)
488
489    (block,) = result.blocks
490    assert block.need["content"] == "@need req: inner"
491    assert [(issue.kind, issue.row) for issue in result.issues] == [
492        (WarningSubTypeEnum.multiline_need_nested_open, 2)
493    ]
494
495
496def test_the_close_line_may_be_indented() -> None:
497    (block,) = parse_run(
498        _lines("@need req: T", "", "Body.", "   @endneed  "), CONFIG
499    ).blocks
500
501    assert block.end_col == 3 + len("@endneed")
502
503
504def test_two_blocks_in_one_run() -> None:
505    lines = _lines("@need req: A", "@endneed", "between", "@need req: B", "@endneed")
506
507    result = parse_run(lines, CONFIG)
508
509    assert [block.need["title"] for block in result.blocks] == ["A", "B"]
510    assert result.claimed_rows == {0, 1, 3, 4}
511
512
513def test_the_project_markers_and_markups() -> None:
514    config = MultilineNeedsConfig(
515        start_sequence="@spec",
516        end_sequence="@endspec",
517        default_markup="myst",
518        markups={"myst": ".md"},
519    )
520
521    result = parse_run(_lines("@need req: X", "@spec req: Y", "@endspec"), config)
522
523    (block,) = result.blocks
524    assert (block.need["title"], block.markup, block.need["doctype"]) == (
525        "Y",
526        "myst",
527        ".md",
528    )
529
530
531# --- precedence -------------------------------------------------------------------------
532
533
534def test_blank_rows_keeps_the_line_count() -> None:
535    text = "/**\n * @need req: A, B\n * @endneed\n * @Other, ID\n */"
536
537    blanked = blank_rows(text, 10, {11, 12})
538
539    assert blanked == "/**\n\n\n * @Other, ID\n */"
540    assert blanked.count("\n") == text.count("\n")
541
542
543# --- through the analysis ---------------------------------------------------------------
544
545
546def _analysis(
547    tmp_path: Path, name: str, source: str | bytes, comment_type: CommentType
548) -> SourceAnalyse:
549    """An analysis of one file with every extractor on, not yet run."""
550    src_path = tmp_path / name
551    if isinstance(source, bytes):
552        src_path.write_bytes(source)
553    else:
554        src_path.write_text(source, encoding="utf-8")
555    analyse = SourceAnalyse(
556        SourceAnalyseConfig(
557            src_files=[src_path],
558            src_dir=tmp_path,
559            comment_type=comment_type,
560            get_oneline_needs=True,
561            get_multiline_needs=True,
562        ),
563        name="p",
564    )
565    analyse.git_remote_url = None
566    analyse.git_commit_rev = None
567    return analyse
568
569
570RUST_RUN = (
571    "/// Parses things.\n"
572    "/// @need impl: Rust block\n"
573    "/// :id: IMPL_RUST\n"
574    "/// @endneed\n"
575    "fn parse() {}\n"
576)
577
578
579def test_a_rust_doc_comment_spans_only_its_own_row(tmp_path: Path) -> None:
580    """A ``///`` node's text ends with its newline; it still covers one row, so the row
581    above the open line neither holds claimed rows nor stands for the block."""
582    analyse = _analysis(tmp_path, "lib.rs", RUST_RUN, CommentType.rust)
583    analyse.create_src_objects()
584
585    claimed = analyse.extract_multiline_needs()
586
587    rows = {
588        comment.node.start_point.row: claimed.get(id(comment), set())
589        for comment in analyse.src_comments
590    }
591    assert rows == {0: set(), 1: {1}, 2: {2}, 3: {3}}
592    (need,) = analyse.multiline_needs
593    assert need.source_comment.node.start_point.row == 1
594
595
596def _record(analyse: SourceAnalyse) -> dict:
597    (need,) = analyse.multiline_needs
598    record = need.to_dict()
599    record.pop("filepath")
600    return record
601
602
603def test_a_record_outside_a_git_repository_is_relative_to_src_dir(
604    tmp_path: Path,
605) -> None:
606    (tmp_path / "src").mkdir()
607    analyse = _analysis(
608        tmp_path, "src/a.cpp", "// @need req: T\n// @endneed\n", CommentType.cpp
609    )
610    assert analyse.git_root is None, "tmp_path must not be inside a repository"
611    analyse.run(log_summary=False)
612
613    record = _record(analyse)
614
615    assert record["source"]["root"] == "src_dir"
616    assert record["source"]["path"] == "src/a.cpp"
617    assert record["source"]["commit"] is None
618    assert record["remote_url"] is None
619
620
621def test_crlf_and_lf_sources_give_the_same_record(tmp_path: Path) -> None:
622    """The parser sees no CR: every entry point normalises line ends first."""
623    lines = [
624        "/**",
625        " * @need req: T",
626        " * :id: REQ_1",
627        " *",
628        " * Body.",
629        " * @endneed",
630        " */",
631    ]
632    lf = _analysis(tmp_path, "lf.cpp", "\n".join(lines) + "\n", CommentType.cpp)
633    crlf = _analysis(
634        tmp_path, "crlf.cpp", ("\r\n".join(lines) + "\r\n").encode(), CommentType.cpp
635    )
636    lf.run(log_summary=False)
637    crlf.run(log_summary=False)
638
639    lf_record, crlf_record = _record(lf), _record(crlf)
640    assert lf_record["need"]["content"] == "Body."
641    assert crlf_record["source"].pop("path") == "crlf.cpp"
642    assert lf_record["source"].pop("path") == "lf.cpp"
643    assert crlf_record == lf_record
644
645
646def test_columns_count_characters_not_bytes(tmp_path: Path) -> None:
647    """tree-sitter gives byte columns; ``é`` is two bytes and one character."""
648    analyse = _analysis(
649        tmp_path,
650        "u.cpp",
651        "int \u00e9 = 0; /* @need req: T\n@endneed */\n",
652        CommentType.cpp,
653    )
654    analyse.run(log_summary=False)
655
656    assert _record(analyse)["source"]["start"] == {"line": 1, "col": 14}
657
658
659def test_a_libclang_record_reads_its_columns_from_the_row(tmp_path: Path) -> None:
660    """A libclang comment has no column: the open line's column comes from its row."""
661    pytest.importorskip("clang.cindex")
662    source = (
663        "void f() {}\n   /** @need impl: Col\n    * @endneed\n    */\nvoid g() {}\n"
664    )
665    analyse = _analysis(tmp_path, "col.cpp", source, CommentType.cpp)
666    analyse.analyse_config.preprocessor = PreprocessorConfig()
667    analyse.run(log_summary=False)
668
669    record = _record(analyse)
670    assert record["source"]["start"] == {"line": 2, "col": 7}
671    assert record["source"]["scope"] is None
672
673
674def _parsed(text: str):
675    (run,) = form_runs([_node(text, 0)])
676    return parse_run(
677        run.lines,
678        CONFIG,
679        leaderless_block=run.leaderless_block,
680        mixed_leaders=run.mixed_leaders,
681    )
682
683
684def test_the_leader_hint_is_only_for_a_block_with_some_leaders() -> None:
685    """A plain block, with no star anywhere, has no missing leader to blame."""
686    (plain,) = _parsed("/*\n @need req: A\n body\n*/").issues
687    (mixed,) = _parsed("/*\n @need req: A\n * body\n*/").issues
688
689    assert plain.kind == mixed.kind == WarningSubTypeEnum.multiline_need_unterminated
690    assert "'*' leader" not in plain.msg
691    assert "'*' leader" in mixed.msg
692
693
694def test_a_starred_one_line_form_in_a_leaderless_block_is_the_one_line_form() -> None:
695    """The refusal order holds in both modes: the one-line form first."""
696    result = _parsed("/*\n * @need req: A @endneed\n   no leader\n */")
697
698    assert [(issue.kind, issue.row) for issue in result.issues] == [
699        (WarningSubTypeEnum.multiline_need_oneline_form, 1)
700    ]
701    assert result.claimed_rows == {1}