[docs]  1# @Test suite for one-line comment parser functionality, TEST_OLP_1, test, [IMPL_OLP_1]
  2import re
  3from pathlib import Path
  4
  5import pytest
  6
  7from sphinx_codelinks.analyse.oneline_parser import (
  8    DOXYGEN_COMMANDS,
  9    OnelineParserInvalidWarning,
 10    WarningSubTypeEnum,
 11    docstring_tag,
 12    doxygen_tag,
 13    jsdoc_tag,
 14    oneline_parser,
 15    opens_powershell_literal,
 16)
 17from sphinx_codelinks.config import ESCAPE, UNIX_NEWLINE, OneLineCommentStyle
 18
 19from .conftest import ONELINE_COMMENT_STYLE, ONELINE_COMMENT_STYLE_DEFAULT
 20
 21
 22@pytest.mark.parametrize(
 23    "oneline, result",
 24    [
 25        (
 26            f"@title 1, IMPL_1 {UNIX_NEWLINE}",
 27            {
 28                "title": "title 1",
 29                "id": "IMPL_1",
 30                "type": "impl",
 31                "links": [],
 32                "start_column": 1,
 33                "end_column": 17,
 34            },
 35        ),
 36        # Test case for leading space after start sequence (bug fix)
 37        (
 38            f"@ title 1, IMPL_1 {UNIX_NEWLINE}",
 39            {
 40                "title": "title 1",
 41                "id": "IMPL_1",
 42                "type": "impl",
 43                "links": [],
 44                "start_column": 1,
 45                "end_column": 18,
 46            },
 47        ),
 48        # Test case for multiple leading spaces after start sequence
 49        (
 50            f"@   title 1, IMPL_1 {UNIX_NEWLINE}",
 51            {
 52                "title": "title 1",
 53                "id": "IMPL_1",
 54                "type": "impl",
 55                "links": [],
 56                "start_column": 1,
 57                "end_column": 20,
 58            },
 59        ),
 60        # Test case for trailing space before end sequence
 61        (
 62            f"@title 1, IMPL_1  {UNIX_NEWLINE}",
 63            {
 64                "title": "title 1",
 65                "id": "IMPL_1",
 66                "type": "impl",
 67                "links": [],
 68                "start_column": 1,
 69                "end_column": 18,
 70            },
 71        ),
 72        # Test case for both leading and trailing spaces
 73        (
 74            f"@  title 1, IMPL_1   {UNIX_NEWLINE}",
 75            {
 76                "title": "title 1",
 77                "id": "IMPL_1",
 78                "type": "impl",
 79                "links": [],
 80                "start_column": 1,
 81                "end_column": 21,
 82            },
 83        ),
 84    ],
 85)
 86def test_oneline_parser_default_config_positive(
 87    oneline: str, result: dict[str, str | list[str]]
 88) -> None:
 89    oneline_need = oneline_parser(oneline, ONELINE_COMMENT_STYLE_DEFAULT)
 90    assert oneline_need == result
 91
 92
 93# Test case for space as field separator (as mentioned by Kilian)
 94# Example: @Implementation <field1> <field2>
 95ONELINE_COMMENT_STYLE_SPACE_SEPARATOR = OneLineCommentStyle(
 96    start_sequence="@",
 97    end_sequence=UNIX_NEWLINE,
 98    field_split_char=" ",
 99    needs_fields=[
100        {"name": "title"},
101        {"name": "id"},
102        {"name": "type", "default": "impl"},
103    ],
104)
105
106
107@pytest.mark.parametrize(
108    "oneline, result",
109    [
110        # Basic space-separated fields
111        (
112            f"@Implementation IMPL_1{UNIX_NEWLINE}",
113            {
114                "title": "Implementation",
115                "id": "IMPL_1",
116                "type": "impl",
117                "start_column": 1,
118                "end_column": 22,
119            },
120        ),
121        # Space separator with explicit type
122        (
123            f"@MyFeature FEAT_001 feature{UNIX_NEWLINE}",
124            {
125                "title": "MyFeature",
126                "id": "FEAT_001",
127                "type": "feature",
128                "start_column": 1,
129                "end_column": 27,
130            },
131        ),
132        # Leading space after @ (the bug Kilian reported)
133        (
134            f"@ Implementation IMPL_2{UNIX_NEWLINE}",
135            {
136                "title": "Implementation",
137                "id": "IMPL_2",
138                "type": "impl",
139                "start_column": 1,
140                "end_column": 23,
141            },
142        ),
143        # Trailing space before newline
144        (
145            f"@Implementation IMPL_3 {UNIX_NEWLINE}",
146            {
147                "title": "Implementation",
148                "id": "IMPL_3",
149                "type": "impl",
150                "start_column": 1,
151                "end_column": 23,
152            },
153        ),
154        # Multiple leading spaces after @
155        (
156            f"@  Title ID_456{UNIX_NEWLINE}",
157            {
158                "title": "Title",
159                "id": "ID_456",
160                "type": "impl",
161                "start_column": 1,
162                "end_column": 15,
163            },
164        ),
165        # Title contain spaces
166        (
167            f"@  Title\ escape\ space ID_456{UNIX_NEWLINE}",
168            {
169                "title": "Title escape space",
170                "id": "ID_456",
171                "type": "impl",
172                "start_column": 1,
173                "end_column": 30,
174            },
175        ),
176    ],
177)
178def test_oneline_parser_space_separator(
179    oneline: str, result: dict[str, str | list[str]]
180) -> None:
181    """Test oneline parser with space as field separator."""
182    oneline_need = oneline_parser(oneline, ONELINE_COMMENT_STYLE_SPACE_SEPARATOR)
183    assert oneline_need == result
184
185
186@pytest.mark.parametrize(
187    "oneline, result",
188    [
189        (
190            "[[IMPL_1, title 1]]",
191            {
192                "id": "IMPL_1",
193                "title": "title 1",
194                "type": "impl",
195                "links": [],
196                "status": "open",
197                "priority": "low",
198                "start_column": 2,
199                "end_column": 17,
200            },
201        ),
202        (
203            "[[IMPL_2, title 2, impl, [], closed]]",
204            {
205                "id": "IMPL_2",
206                "title": "title 2",
207                "type": "impl",
208                "links": [],
209                "status": "closed",
210                "priority": "low",
211                "start_column": 2,
212                "end_column": 35,
213            },
214        ),
215        (
216            "[[IMPL_3, title\, 3, impl, [], closed]]",
217            {
218                "id": "IMPL_3",
219                "title": "title, 3",
220                "type": "impl",
221                "links": [],
222                "status": "closed",
223                "priority": "low",
224                "start_column": 2,
225                "end_column": 37,
226            },
227        ),
228        (
229            "[[IMPL_5, title 5, impl, [SPEC_1, SPEC_2], open]]",
230            {
231                "id": "IMPL_5",
232                "title": "title 5",
233                "type": "impl",
234                "links": ["SPEC_1", "SPEC_2"],
235                "status": "open",
236                "priority": "low",
237                "start_column": 2,
238                "end_column": 47,
239            },
240        ),
241        (
242            "[[IMPL_7, Function has a, in the title]]",
243            {
244                "id": "IMPL_7",
245                "title": "Function has a",
246                "type": "in the title",
247                "links": [],
248                "status": "open",
249                "priority": "low",
250                "start_column": 2,
251                "end_column": 38,
252            },
253        ),
254        (
255            "[[IMPL_8, [Title starts with a bracket], impl]]",
256            {
257                "id": "IMPL_8",
258                "title": "[Title starts with a bracket]",
259                "type": "impl",
260                "links": [],
261                "status": "open",
262                "priority": "low",
263                "start_column": 2,
264                "end_column": 45,
265            },
266        ),
267        (
268            "[[IMPL_9, Function Baz, impl, [SPEC_1, SPEC_2[text], SPEC_3], open]]",
269            {
270                "id": "IMPL_9",
271                "title": "Function Baz",
272                "type": "impl",
273                "links": ["SPEC_1", "SPEC_2[text"],
274                "status": "SPEC_3]",
275                "priority": "open",
276                "start_column": 2,
277                "end_column": 66,
278            },
279        ),
280        (
281            "[[IMPL_10, title 10, impl, [SPEC_1], open]]",
282            {
283                "id": "IMPL_10",
284                "title": "title 10",
285                "type": "impl",
286                "links": ["SPEC_1"],
287                "status": "open",
288                "priority": "low",
289                "start_column": 2,
290                "end_column": 41,
291            },
292        ),
293        (
294            "[[IMPL_11, title 11, impl, [SPEC\,_1], open]]",
295            {
296                "id": "IMPL_11",
297                "title": "title 11",
298                "type": "impl",
299                "links": ["SPEC,_1"],
300                "status": "open",
301                "priority": "low",
302                "start_column": 2,
303                "end_column": 43,
304            },
305        ),
306        (
307            "[[IMPL_12, title 12, impl, [\[SPEC\,_1\]], open]]",
308            {
309                "id": "IMPL_12",
310                "title": "title 12",
311                "type": "impl",
312                "links": ["[SPEC,_1]"],
313                "status": "open",
314                "priority": "low",
315                "start_column": 2,
316                "end_column": 47,
317            },
318        ),
319        (
320            "[[IMPL_13, title\\ 13, impl, [\[SPEC\,_1\]], open]]",
321            {
322                "id": "IMPL_13",
323                "title": "title\ 13",
324                "type": "impl",
325                "links": ["[SPEC,_1]"],
326                "status": "open",
327                "priority": "low",
328                "start_column": 2,
329                "end_column": 48,
330            },
331        ),
332    ],
333)
334def test_oneline_parser_custom_config_positive(
335    oneline: str, result: dict[str, str | list[str]]
336) -> None:
337    oneline_need = oneline_parser(oneline, ONELINE_COMMENT_STYLE)
338    assert oneline_need == result
339
340
341@pytest.mark.parametrize(
342    "oneline, result",
343    [
344        (
345            f"[[IMPL_4, title{ESCAPE}{ESCAPE}, 4, impl, [], closed]]",
346            OnelineParserInvalidWarning(
347                sub_type=WarningSubTypeEnum.missing_square_brackets,
348                msg="Field 'links' with 'type': 'list[str]' must be given with '[]' brackets",
349            ),
350        ),
351        (
352            "[[IMPL_2, Function Bar, impl, [SPEC_1, SPEC_2, open]]",
353            OnelineParserInvalidWarning(
354                sub_type=WarningSubTypeEnum.missing_square_brackets,
355                msg="Field 'links' with 'type': 'list[str]' must be given with '[]' brackets",
356            ),
357        ),
358        (
359            "[[IMPL_13, title 13, impl, 13[\[SPEC\,_1\]], open]]",
360            OnelineParserInvalidWarning(
361                sub_type=WarningSubTypeEnum.not_start_or_end_with_square_brackets,
362                msg="Field 'links' with 'type': 'list[str]' must start with '[' and end with ']'",
363            ),
364        ),
365        (
366            "[[IMPL_14, title 13, impl, 13[\[SPEC\,_1\]], open, low, high]]",
367            OnelineParserInvalidWarning(
368                sub_type=WarningSubTypeEnum.too_many_fields,
369                msg="7 given fields, maximum is 6",
370            ),
371        ),
372        (
373            "[[IMPL_15]]",
374            OnelineParserInvalidWarning(
375                sub_type=WarningSubTypeEnum.too_few_fields,
376                msg="1 given fields, minimum is 2",
377            ),
378        ),
379        (
380            f"[[IMPL_16]]{UNIX_NEWLINE}, title 16]]",
381            OnelineParserInvalidWarning(
382                sub_type=WarningSubTypeEnum.newline_in_field,
383                msg="Field 'id' contains a newline character",
384            ),
385        ),
386    ],
387)
388def test_oneline_parser_custom_config_negative(
389    oneline: str, result: OnelineParserInvalidWarning
390) -> None:
391    res = oneline_parser(oneline, ONELINE_COMMENT_STYLE)
392    assert res == result
393
394
395@pytest.mark.parametrize(
396    "oneline, result",
397    [
398        (
399            f"@title 17]]{UNIX_NEWLINE}, IMPL_17 {UNIX_NEWLINE}",
400            OnelineParserInvalidWarning(
401                sub_type=WarningSubTypeEnum.newline_in_field,
402                msg="Field 'title' contains a newline character",
403            ),
404        ),
405        (
406            f"@title 17]], IMPL_17, impl, [SPEC_3, SPEC_4{UNIX_NEWLINE} ] {UNIX_NEWLINE}",
407            OnelineParserInvalidWarning(
408                sub_type=WarningSubTypeEnum.newline_in_field,
409                msg="Field 'links' contains a newline character",
410            ),
411        ),
412    ],
413)
414def test_oneline_parser_default_config_negative(
415    oneline: str, result: OnelineParserInvalidWarning
416) -> None:
417    assert oneline_parser(oneline, ONELINE_COMMENT_STYLE_DEFAULT) == result
418
419
420@pytest.mark.parametrize(
421    "oneline_config, result",
422    [
423        (
424            OneLineCommentStyle(
425                start_sequence="[[",
426                end_sequence="]]",
427                field_split_char=",",
428                needs_fields=[
429                    {"name": "title"},
430                    {"name": "id"},
431                    {"name": "type", "default": "impl"},
432                    {"name": "links", "type": "list[]", "default": []},  # wrong type
433                ],
434            ),
435            [
436                "Schema validation error in need_fields 'links': 'list[]' is not one of ['str', 'list[str]']"
437            ],
438        ),
439        (
440            OneLineCommentStyle(
441                start_sequence="[[",
442                end_sequence="]]",
443                field_split_char=",",
444                needs_fields=[
445                    {"name": "title"},
446                    {"name": "id"},
447                    {"name": "type", "default": 123},  # int is invalid
448                    {"name": "links", "type": "list[str]", "default": []},
449                ],
450            ),
451            [
452                "Schema validation error in need_fields 'type': 123 is not of type 'string'"
453            ],
454        ),
455        (
456            OneLineCommentStyle(
457                start_sequence="[[",
458                end_sequence="]]",
459                field_split_char=",",
460                needs_fields=[
461                    {"name": "title", "qwe": "qwe"},  # invalid qwe filed
462                    {"name": "id"},
463                    {"name": "type", "default": "impl"},
464                    {"name": "links", "type": "list[str]", "default": []},
465                ],
466            ),
467            [
468                "Schema validation error in need_fields 'title': Additional properties are not allowed ('qwe' was unexpected)"
469            ],
470        ),
471        (
472            OneLineCommentStyle(
473                start_sequence="[[",
474                end_sequence="]]",
475                field_split_char=",",
476                needs_fields=[
477                    {"name": "title"},
478                    {"name": "id"},
479                    {
480                        "name": "type",
481                        "type: ": "list[str]",
482                        "default": "impl",
483                    },  # wring combination of type and default
484                    {"name": "links", "type": "list[str]", "default": []},
485                ],
486            ),
487            [
488                "Schema validation error in need_fields 'type': Additional properties are not allowed ('type: ' was unexpected)"
489            ],
490        ),
491        (
492            OneLineCommentStyle(
493                start_sequence="[[",
494                end_sequence="]]",
495                field_split_char=",",
496                needs_fields=[
497                    {"name": "id"}  # "title" and "type" are not given
498                ],
499            ),
500            ["Missing required fields: ['title', 'type']"],
501        ),
502        (
503            OneLineCommentStyle(
504                start_sequence="[[",
505                end_sequence="]]",
506                field_split_char=",",
507                needs_fields=[
508                    {"name": "id"},
509                    {"name": "id"},  # duplicate
510                ],
511            ),
512            [
513                "Missing required fields: ['title', 'type']",
514                "Field 'id' is defined multiple times.",
515            ],
516        ),
517        (
518            OneLineCommentStyle(
519                start_sequence=1234,  # wrong type
520                end_sequence=5678,
521                field_split_char=2222,
522                needs_fields=[
523                    {"name": "id"},
524                ],
525            ),
526            [
527                "Schema validation error in field 'field_split_char': 2222 is not of type 'string'",
528                "Schema validation error in field 'end_sequence': 5678 is not of type 'string'",
529                "Schema validation error in field 'start_sequence': 1234 is not of type 'string'",
530                "Missing required fields: ['title', 'type']",
531            ],
532        ),
533    ],
534)
535def test_oneline_schema_validator_negative(oneline_config, result):
536    errors = oneline_config.check_fields_configuration()
537    assert sorted(errors) == sorted(result)
538
539
540@pytest.mark.parametrize(
541    "oneline",
542    [
543        # A start sequence embedded in free-form prose (word chars before it)
544        # must not be treated as a one-line marker (issue #88).
545        f"// Some prose that mentions @@another-tag(item_1, item_2): more text{UNIX_NEWLINE}",
546        f"// See @author, check the example{UNIX_NEWLINE}",
547        f"// We parse things matching @pattern, then act{UNIX_NEWLINE}",
548    ],
549)
550def test_oneline_parser_ignores_start_sequence_in_prose(oneline: str) -> None:
551    """Issue #88: only anchor a marker at the start of the comment content."""
552    assert oneline_parser(oneline, ONELINE_COMMENT_STYLE_DEFAULT) is None
553
554
555@pytest.mark.parametrize(
556    "oneline, expected_id",
557    [
558        (f"// @My Title, IMPL_1{UNIX_NEWLINE}", "IMPL_1"),  # line-comment leader
559        (f"    // @My Title, IMPL_2{UNIX_NEWLINE}", "IMPL_2"),  # indented
560        (f"* @My Title, IMPL_3{UNIX_NEWLINE}", "IMPL_3"),  # block-comment continuation
561        (f"/// @My Title, IMPL_4{UNIX_NEWLINE}", "IMPL_4"),  # doc comment
562        (f"//! @My Title, IMPL_5{UNIX_NEWLINE}", "IMPL_5"),  # inner doc comment
563    ],
564)
565def test_oneline_parser_marker_preceded_only_by_comment_decoration(
566    oneline: str, expected_id: str
567) -> None:
568    """A marker preceded only by comment syntax/whitespace is still recognized."""
569    res = oneline_parser(oneline, ONELINE_COMMENT_STYLE_DEFAULT)
570    assert isinstance(res, dict)
571    assert res["id"] == expected_id
572
573
574def test_oneline_parser_bounded_marker_allowed_after_prose() -> None:
575    """Explicitly-bounded markers ([[ ... ]]) are self-delimiting, so they may
576    be embedded after prose; only newline-terminated markers are anchored."""
577    oneline = "// one-line comment style: [[IMPL_1, title 1]]"
578    res = oneline_parser(oneline, ONELINE_COMMENT_STYLE)
579    assert isinstance(res, dict)
580    assert res["id"] == "IMPL_1"
581
582
583#: one required field (``title``): a line without the separator is a whole marker
584ONE_REQUIRED_FIELD = OneLineCommentStyle(
585    needs_fields=[{"name": "title"}, {"name": "type", "default": "impl"}]
586)
587
588
589@pytest.mark.parametrize(
590    "oneline",
591    [
592        f"// @param x the value{UNIX_NEWLINE}",
593        f"/// @return nothing{UNIX_NEWLINE}",
594        f" * @only{UNIX_NEWLINE}",
595    ],
596)
597def test_one_character_start_without_separator_is_not_a_marker(oneline: str) -> None:
598    """A one-character start sequence, no field separator in the content and more than
599    one required field: documentation tags such as ``@param`` are not markers, and not
600    warnings either."""
601    assert oneline_parser(oneline, ONELINE_COMMENT_STYLE_DEFAULT) is None
602
603
604@pytest.mark.parametrize(
605    "oneline, sub_type",
606    [
607        (
608            f"// @see A, B, C, D, E{UNIX_NEWLINE}",
609            WarningSubTypeEnum.too_many_fields,
610        ),
611        # the fourth field is ``links``, a ``list[str]``
612        (
613            f"// @brief a, b, c, d{UNIX_NEWLINE}",
614            WarningSubTypeEnum.missing_square_brackets,
615        ),
616    ],
617)
618def test_one_character_start_with_separator_still_warns(
619    oneline: str, sub_type: WarningSubTypeEnum
620) -> None:
621    res = oneline_parser(oneline, ONELINE_COMMENT_STYLE_DEFAULT)
622    assert isinstance(res, OnelineParserInvalidWarning)
623    assert res.sub_type == sub_type
624
625
626def test_one_character_start_with_two_fields_is_still_a_need() -> None:
627    """A line with the separator, ``@brief Does a, b``, is the need it always was."""
628    assert oneline_parser(
629        f"// @brief Does a, b{UNIX_NEWLINE}", ONELINE_COMMENT_STYLE_DEFAULT
630    ) == {
631        "title": "brief Does a",
632        "id": "b",
633        "type": "impl",
634        "links": [],
635        "start_column": 4,
636        "end_column": 19,
637    }
638
639
640def test_multi_character_start_without_separator_still_warns() -> None:
641    """``[[`` is specific enough: a marker with too few fields is a warning."""
642    assert oneline_parser(
643        "// [[ only-title ]]", ONELINE_COMMENT_STYLE
644    ) == OnelineParserInvalidWarning(
645        sub_type=WarningSubTypeEnum.too_few_fields,
646        msg="1 given fields, minimum is 2",
647    )
648
649
650def test_one_character_start_with_one_required_field_is_a_need() -> None:
651    """With one required field, a line without the separator is a whole marker."""
652    assert oneline_parser(f"// @x{UNIX_NEWLINE}", ONE_REQUIRED_FIELD) == {
653        "title": "x",
654        "type": "impl",
655        "start_column": 4,
656        "end_column": 5,
657    }
658
659
660@pytest.mark.parametrize(
661    "oneline, style",
662    [
663        ("// [[ ]]", ONELINE_COMMENT_STYLE),
664        (f"// @{UNIX_NEWLINE}", ONE_REQUIRED_FIELD),
665        (f"// @{UNIX_NEWLINE}", ONELINE_COMMENT_STYLE_DEFAULT),
666        (f"// @   {UNIX_NEWLINE}", ONELINE_COMMENT_STYLE_DEFAULT),
667    ],
668)
669def test_empty_content_is_not_a_marker(
670    oneline: str, style: OneLineCommentStyle
671) -> None:
672    """Nothing between the start and end sequences: not a marker, whatever the start
673    sequence and however many fields are required."""
674    assert oneline_parser(oneline, style) is None
675
676
677def test_an_escaped_separator_counts_as_present() -> None:
678    """``\\,`` is a separator in the content, so the line is a marker, and one field
679    is too few."""
680    res = oneline_parser(f"// @a\\,b{UNIX_NEWLINE}", ONELINE_COMMENT_STYLE_DEFAULT)
681    assert isinstance(res, OnelineParserInvalidWarning)
682    assert res.sub_type == WarningSubTypeEnum.too_few_fields
683
684
685def test_a_non_ascii_start_sequence_of_one_character_is_one_character() -> None:
686    """One character is one code point: ``§`` is a one-character start sequence."""
687    style = OneLineCommentStyle(start_sequence="§")
688    assert oneline_parser(f"// §only{UNIX_NEWLINE}", style) is None
689    res = oneline_parser(f"// §a title, IMPL_SECT{UNIX_NEWLINE}", style)
690    assert isinstance(res, dict)
691    assert res["id"] == "IMPL_SECT"
692
693
694@pytest.mark.parametrize(
695    "line, expected",
696    [
697        pytest.param("@param a: the first, thing", "param", id="param_field"),
698        pytest.param("@return: nothing", "return", id="return_field"),
699        pytest.param("@raise ValueError: on bad input", "raise", id="raise_field"),
700        pytest.param("@type x_1: int", "type", id="type_field"),
701        pytest.param("@param a:", "param", id="field_empty_text"),
702        pytest.param('"""@return: nothing', "return", id="opening_quotes"),
703        pytest.param("@todo fix the parser, IMPL_DS, impl", None, id="todo_title"),
704        pytest.param(
705            '"""@todo fix the parser, IMPL_DS, impl"""',
706            None,
707            id="todo_title_opening_quotes",
708        ),
709        pytest.param("@param a the first, thing", None, id="doxygen_comma_form"),
710        pytest.param("@return", None, id="bare_tag"),
711        pytest.param("@param a b: text", None, id="two_words_before_colon"),
712        pytest.param("@param a : text", None, id="space_before_colon"),
713        pytest.param(
714            "@Todo fix the parser, IMPL_TODO2, impl", None, id="case_sensitive"
715        ),
716        pytest.param("@params a: text", None, id="not_a_tag"),
717        pytest.param("see @param a: text", None, id="text_before_start"),
718        # the field word is any run of characters other than space, tab and ``:``,
719        # after spaces or tabs only: ``:|[ \t]+[^ \t:]+:``, locale-free
720        pytest.param("@param नाम: text", "param", id="non_ascii_word_devanagari"),
721        pytest.param("@param ä: text", "param", id="non_ascii_word_latin"),
722        pytest.param("@param *args: text", "param", id="star_args_word"),
723        pytest.param("@type a.b: int", "type", id="dotted_word"),
724        pytest.param("@param\ta: text", "param", id="tab_separator"),
725        pytest.param("@param\x1ca: text", None, id="unit_separator_is_not_a_space"),
726        pytest.param(
727            "@param\u3000a: text", None, id="ideographic_space_is_not_a_space"
728        ),
729        pytest.param("@return : text", None, id="empty_word_before_colon"),
730        pytest.param("@param : text", None, id="empty_word_after_param"),
731        pytest.param("@param1: text", None, id="digit_glued_to_tag"),
732        pytest.param("@return_x: text", None, id="underscore_glued_to_tag"),
733    ],
734)
735def test_docstring_tag_field_shape(line: str, expected: str | None) -> None:
736    """A docstring line is a ``docstring_tag`` only when a listed tag right after the
737    start sequence has the Epydoc field shape: ``:`` directly, or spaces or tabs, one
738    word without space, tab or ``:``, and ``:``. The same table as ubCode's."""
739    assert docstring_tag(line, "@") == expected
740
741
742@pytest.mark.parametrize(
743    ("line", "expected"),
744    [
745        pytest.param(" * @param a the first, thing", "param", id="param"),
746        pytest.param(" * @param {number} a text", "param", id="param_type"),
747        pytest.param(" * @param{number} a text", "param", id="brace_glued_to_tag"),
748        pytest.param(" * @returns", "returns", id="bare_tag"),
749        pytest.param(" * @returns\n", "returns", id="bare_tag_terminated"),
750        pytest.param(" * @deprecated\tUse x, it is faster", "deprecated", id="tab"),
751        pytest.param(" * {@link Foo}, the original", "link", id="inline_tag"),
752        pytest.param(" * {@inheritDoc}", "inheritDoc", id="inline_tag_closed"),
753        pytest.param("@typeParam T the type, of it", "typeParam", id="camel_case_tag"),
754        pytest.param(" * @Param check, IMPL_1, impl", None, id="case_sensitive"),
755        pytest.param(" * @parameters Check, IMPL_1, impl", None, id="longer_word"),
756        pytest.param(" * @param1 text", None, id="digit_glued_to_tag"),
757        pytest.param(" * @param-a text", None, id="dash_glued_to_tag"),
758        pytest.param(" * @param: text", None, id="colon_glued_to_tag"),
759        pytest.param(" * see @param a text", None, id="text_before_start"),
760        pytest.param(" * @Real Need, IMPL_1, impl", None, id="not_a_tag"),
761        pytest.param(
762            " * @param\u3000a text", None, id="ideographic_space_ends_nothing"
763        ),
764    ],
765)
766def test_jsdoc_tag(line: str, expected: str | None) -> None:
767    """A JSDoc comment line is a ``jsdoc_tag`` only when a listed tag right after the
768    start sequence ends at a space, a tab, a ``{``, a ``}`` or the end of the line. The
769    same table as ubCode's."""
770    assert jsdoc_tag(line, "@") == expected
771
772
773@pytest.mark.parametrize(
774    ("line", "start_sequence", "expected"),
775    [
776        ("# @{ Keys = 'k' }, b, impl", "@", True),
777        ("# @(1, 2) | Measure-Object", "@", True),
778        ("    @{ Name = 'a' } | Should-BeHashtable", "@", True),
779        ("# `@(9, 9, 1) -eq 1` returns 1", "@", True),
780        ("# @Title, ID_1, impl", "@", False),
781        # the start sequence's FIRST occurrence decides, as the one-line parser takes it
782        ("# @Title, ID_1, impl @{ a }", "@", False),
783        ("# @ {spaced}, ID_1, impl", "@", False),
784        # any start sequence: ``${`` and ``$(`` are PowerShell code too
785        ("# ${env:PATH}, ID_1, impl", "$", True),
786        ("# no start sequence here", "@", False),
787        ("# @{", "", False),
788    ],
789)
790def test_opens_powershell_literal(
791    line: str, start_sequence: str, expected: bool
792) -> None:
793    """A PowerShell line opens a hashtable or array literal, not a marker, when its
794    first start sequence is directly followed by ``{`` or ``(``."""
795    assert opens_powershell_literal(line, start_sequence) is expected
796
797
798@pytest.mark.parametrize(
799    ("line", "expected"),
800    [
801        pytest.param("--! @param clk Clock input, rising edge", "param", id="param"),
802        pytest.param("--! @param[in] rst Reset, active high", "param", id="bracket"),
803        pytest.param("--! @code{.vhd} x, y", "code", id="brace"),
804        pytest.param("--! @brief", "brief", id="bare_command"),
805        pytest.param("--! @brief\n", "brief", id="bare_command_terminated"),
806        pytest.param("--!\t@note\tOne, two", "note", id="tab"),
807        pytest.param("--! @Param check, IMPL_1, impl", None, id="case_sensitive"),
808        pytest.param("--! @parameters Check, IMPL_1, impl", None, id="longer_word"),
809        pytest.param("--! @param1 text", None, id="digit_glued_to_command"),
810        pytest.param("--! @param: text", None, id="colon_glued_to_command"),
811        pytest.param("--! see @param a text", None, id="text_before_start"),
812        pytest.param("--! @Real Need, IMPL_1, impl", None, id="not_a_command"),
813        pytest.param("--! @satisfies REQ_1, REQ_2", "satisfies", id="satisfies"),
814        pytest.param("--! @verifies REQ_3, REQ_4", "verifies", id="verifies"),
815        pytest.param("--! @requirement R Login, x", "requirement", id="requirement"),
816        pytest.param("--! @mermaid{flow.mmd} a, b", "mermaid", id="mermaid"),
817    ],
818)
819def test_doxygen_tag(line: str, expected: str | None) -> None:
820    """A Doxygen comment line is a ``doxygen_tag`` only when a listed command right after
821    the start sequence ends at a space, a tab, a ``[``, a ``{`` or the end of the line."""
822    assert doxygen_tag(line, "@") == expected
823
824
825def test_doxygen_commands_are_the_corpus_list() -> None:
826    """``DOXYGEN_COMMANDS`` is the list the shared corpus file ``doxygen.yaml`` writes in
827    its header, command for command, and the count the header gives."""
828    header = (Path(__file__).parent / "data" / "extraction" / "doxygen.yaml").read_text(
829        encoding="utf-8"
830    )
831    match = re.search(
832        r"^# The commands \((\d+)\)(?:.*\n)*?#\n((?:#   .*\n)+)", header, re.M
833    )
834    assert match is not None
835    listed = {
836        word.strip()
837        for line in match.group(2).splitlines()
838        for word in line.removeprefix("#").split(",")
839        if word.strip()
840    }
841    assert listed == DOXYGEN_COMMANDS
842    assert int(match.group(1)) == len(listed)