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