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