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