[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}