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