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