[docs]   1# @Test suite for tree-sitter parsing utilities and language support, TEST_LANG_1, test, [IMPL_LANG_1, IMPL_EXTR_1]
   2import shutil
   3import subprocess
   4from pathlib import Path
   5
   6import pytest
   7from tree_sitter import Node as TreeSitterNode
   8from tree_sitter import Parser, Query
   9
  10from sphinx_codelinks.analyse import utils
  11from sphinx_codelinks.source_discover.config import COMMENT_FILETYPE, CommentType
  12
  13
  14# The fixtures build the parsers the way analysis does, through init_tree_sitter.
  15@pytest.fixture(scope="session")
  16def init_cpp_tree_sitter() -> tuple[Parser, Query]:
  17    return utils.init_tree_sitter(CommentType.cpp)
  18
  19
  20@pytest.fixture(scope="session")
  21def init_python_tree_sitter() -> tuple[Parser, Query]:
  22    return utils.init_tree_sitter(CommentType.python)
  23
  24
  25@pytest.fixture(scope="session")
  26def init_csharp_tree_sitter() -> tuple[Parser, Query]:
  27    return utils.init_tree_sitter(CommentType.cs)
  28
  29
  30@pytest.fixture(scope="session")
  31def init_yaml_tree_sitter() -> tuple[Parser, Query]:
  32    return utils.init_tree_sitter(CommentType.yaml)
  33
  34
  35@pytest.fixture(scope="session")
  36def init_rust_tree_sitter() -> tuple[Parser, Query]:
  37    return utils.init_tree_sitter(CommentType.rust)
  38
  39
  40@pytest.fixture(scope="session")
  41def init_typescript_tree_sitter() -> tuple[Parser, Query]:
  42    # A .tsx file: the TSX grammar, which the JSX fixtures below need
  43    return utils.init_tree_sitter(CommentType.ts, Path("fixture.tsx"))
  44
  45
  46@pytest.fixture(scope="session")
  47def init_go_tree_sitter() -> tuple[Parser, Query]:
  48    return utils.init_tree_sitter(CommentType.go)
  49
  50
  51@pytest.fixture(scope="session")
  52def init_jsonc_tree_sitter() -> tuple[Parser, Query]:
  53    return utils.init_tree_sitter(CommentType.jsonc)
  54
  55
  56@pytest.fixture(scope="session")
  57def init_bash_tree_sitter() -> tuple[Parser, Query]:
  58    return utils.init_tree_sitter(CommentType.bash)
  59
  60
  61@pytest.fixture(scope="session")
  62def init_powershell_tree_sitter() -> tuple[Parser, Query]:
  63    return utils.init_tree_sitter(CommentType.powershell)
  64
  65
  66def test_init_tree_sitter_powershell(init_powershell_tree_sitter):
  67    """Both PowerShell comment forms, ``#`` and ``<# #>``, are comment nodes."""
  68    parser, query = init_powershell_tree_sitter
  69    comments = utils.extract_comments(b"# a\n<# b #>\nfunction f { }\n", parser, query)
  70    assert comments
  71    assert [comment.text for comment in comments] == [b"# a", b"<# b #>"]
  72
  73
  74@pytest.mark.parametrize(
  75    "code",
  76    [
  77        # a here-string whose ``#`` line is not its first line
  78        b'$stub = @"\nfunction Test-It {\n# @Generated, IMPL_GEN, impl\n}\n"@\n',
  79        # an interpolating here-string
  80        b'$name = "px"\n$t = @"\nHello $name\n# @Interp, IMPL_INTERP, impl\n"@\n',
  81    ],
  82)
  83def test_a_hash_line_in_a_powershell_here_string_is_no_comment(
  84    code, init_powershell_tree_sitter
  85):
  86    """The grammar reads a ``#`` line inside an expandable here-string as a comment
  87    node; it is string content, so extraction leaves it out."""
  88    parser, query = init_powershell_tree_sitter
  89    comments = utils.extract_comments(code + b"# real\nfunction f { }\n", parser, query)
  90    assert comments
  91    assert [comment.text for comment in comments] == [b"# real"]
  92
  93
  94@pytest.mark.parametrize(
  95    "code",
  96    [
  97        # an ordinary double-quoted string that interpolates before its ``#`` line
  98        # and on it
  99        b'$c = "one $x\n# @Fake, ID_FAKE, impl $y\nthree"\n',
 100        # the interpolation first in the string
 101        b'$c = "$x\n# @Fake, ID_FAKE, impl $y\nthree"\n',
 102        # a subexpression is an interpolation too
 103        b'$c = "one $($x)\n# @Fake, ID_FAKE, impl $($y)\nthree"\n',
 104        # a string passed to a command, the ``#`` indented
 105        b'Write-Host "one $x\n    # @Fake $y, ID_FAKE, impl\nthree"\n',
 106        # the ``#`` on the interpolation's own line
 107        b'$c = "one $x # @Fake, ID_FAKE, impl $y\nthree"\n',
 108        # right after the interpolation: the comment node starts with its ``#``
 109        b'$c = "$x# @Fake, ID_FAKE, impl $y\nthree"\n',
 110    ],
 111)
 112def test_a_hash_line_in_an_interpolating_powershell_string_is_no_comment(
 113    code, init_powershell_tree_sitter
 114):
 115    """After an interpolation in a double-quoted string, the grammar may read a ``#``
 116    that follows it past whitespace alone as a comment node inside the string, as it
 117    does in a here-string: it is string content, never a marker."""
 118    parser, query = init_powershell_tree_sitter
 119    comments = utils.extract_comments(code + b"# real\nfunction f { }\n", parser, query)
 120    assert comments
 121    assert [comment.text for comment in comments] == [b"# real"]
 122
 123
 124@pytest.mark.parametrize(
 125    "code",
 126    [
 127        # the string's closing quote on the ``#`` line: the grammar's comment takes the
 128        # quote in and lands under an ERROR node, in no string
 129        b'$s = "one $x\n# @Fake, ID_F, impl"\n',
 130        b'$s = "one $x # @Fake, ID_F, impl"\n',
 131        b'Write-Host "$x\n    # @Fake, ID_F, impl"\n',
 132    ],
 133)
 134def test_a_powershell_comment_token_that_is_no_comment_is_left_out(
 135    code, init_powershell_tree_sitter
 136):
 137    """A PowerShell comment opens with ``#`` or ``<#``; the comment node the grammar
 138    makes of a ``#`` after an interpolation starts with the whitespace before it, and
 139    where it is in no string node (the string's quote is lost to it), it is still no
 140    comment of the file."""
 141    parser, query = init_powershell_tree_sitter
 142    comments = utils.extract_comments(code, parser, query) or []
 143    assert [c.text for c in comments if not (c.text or b"").startswith(b"#")] == []
 144
 145
 146@pytest.mark.parametrize(
 147    "code",
 148    [
 149        # a subexpression in a here-string is code
 150        b'$s = @"\n$( # in code\n 1 )\n"@\n',
 151        # and in an ordinary double-quoted string
 152        b'$s = "a $( # in code\n 1 ) b"\n',
 153    ],
 154)
 155def test_a_comment_in_a_powershell_subexpression_is_a_comment(
 156    code, init_powershell_tree_sitter
 157):
 158    """A comment inside ``$( … )`` is a comment of the code the string interpolates,
 159    not string content: it is kept, string or not around it."""
 160    parser, query = init_powershell_tree_sitter
 161    comments = utils.extract_comments(code, parser, query)
 162    assert comments
 163    assert [comment.text for comment in comments] == [b"# in code"]
 164
 165
 166def test_every_comment_type_is_wired():
 167    """A comment type is a ``CommentType`` member plus an entry in each of several
 168    tables; a member one of them misses fails here, rather than as a ``KeyError`` in
 169    discovery or as no scope at all."""
 170    members = set(CommentType)
 171    assert set(COMMENT_FILETYPE) == {member.value for member in members}
 172    # YAML and JSONC bind to data structures, not scopes (find_associated_scope)
 173    assert set(utils.SCOPE_NODE_TYPES) == members - {
 174        CommentType.yaml,
 175        CommentType.jsonc,
 176    }
 177    assert set(utils.WRAPPER_TYPES) <= set(utils.ENTITY_NODE_TYPES)
 178    assert set(utils.TRIVIA_TYPES) <= set(utils.ENTITY_NODE_TYPES)
 179    for comment_type, predicates in utils.SCOPE_PREDICATES.items():
 180        assert set(predicates) <= utils.SCOPE_NODE_TYPES[comment_type]
 181    for member in members:
 182        utils.init_tree_sitter(member)
 183
 184
 185@pytest.mark.parametrize(
 186    ("code", "result"),
 187    [
 188        (
 189            b"""
 190                // @req-id: need_001
 191                void dummy_func1(){
 192                }
 193            """,
 194            "void dummy_func1()",
 195        ),
 196        (
 197            b"""
 198                void dummy_func2(){
 199                }
 200                // @req-id: need_001
 201                void dummy_func1(){
 202                }
 203            """,
 204            "void dummy_func1()",
 205        ),
 206        (
 207            b"""
 208                void dummy_func1(){
 209                    a = 1;
 210                    /* @req-id: need_001 */
 211                }
 212            """,
 213            "void dummy_func1()",
 214        ),
 215        (
 216            b"""
 217                void dummy_func1(){
 218                    // @req-id: need_001
 219                    a = 1;
 220                }
 221                void dummy_func2(){
 222                }
 223            """,
 224            "void dummy_func1()",
 225        ),
 226    ],
 227)
 228def test_find_associated_scope_cpp(code, result, init_cpp_tree_sitter):
 229    parser, query = init_cpp_tree_sitter
 230    comments = utils.extract_comments(code, parser, query)
 231    node: TreeSitterNode | None = utils.find_associated_scope(
 232        comments[0], CommentType.cpp
 233    )
 234    assert node
 235    assert node.text
 236    func_def = node.text.decode("utf-8")
 237    assert result in func_def
 238
 239
 240#: What :func:`_attached_to` gives for a marker rule 7 attaches to nothing
 241ABOVE_CONDITIONAL = utils.Unattached.above_conditional
 242
 243
 244def _attached_to(
 245    source: str, parser: Parser, query: Query
 246) -> tuple[str, str, str] | utils.Unattached | None:
 247    """What the comment holding ``marker`` in the C++ ``source`` is attached to: the
 248    entity's node kind, its first line and the mechanism; :data:`ABOVE_CONDITIONAL`
 249    for nothing by rule 7, ``None`` for nothing by any other rule."""
 250    comments = utils.extract_comments(source.encode(), parser, query) or []
 251    marker = next(c for c in comments if b"marker" in (c.text or b""))
 252    association = utils.associate(marker, CommentType.cpp)
 253    if association is None or isinstance(association, utils.Unattached):
 254        return association
 255    first_line = (association.node.text or b"").decode().splitlines()[0]
 256    return association.node.type, first_line, association.mechanism
 257
 258
 259#: The conditionals a marker can stand directly above, each the body of a namespace or a
 260#: class with the marker as its first line
 261CONDITIONAL_ARMS = {
 262    "one-arm": "#if FEATURE_B\nvoid active() {}\n#endif\n",
 263    "ifdef": "#ifdef FEATURE_B\nvoid active() {}\n#endif\n",
 264    "ifndef": "#ifndef FEATURE_B\nvoid active() {}\n#endif\n",
 265    "else-different-functions": (
 266        "#if FEATURE_B\nvoid active() {}\n#else\nvoid fallback() {}\n#endif\n"
 267    ),
 268    "else-same-function": (
 269        "#if FEATURE_B\nvoid active() { fast(); }\n"
 270        "#else\nvoid active() { slow(); }\n#endif\n"
 271    ),
 272    "elif": (
 273        "#if FEATURE_A\nvoid a() {}\n#elif FEATURE_B\nvoid b() {}\n"
 274        "#else\nvoid c() {}\n#endif\n"
 275    ),
 276    "declaration-only": "#if FEATURE_B\nvoid declared();\n#endif\n",
 277}
 278
 279#: The marker as the last line of an arm, directly above the next directive of the
 280#: same conditional
 281ARM_ENDINGS = {
 282    "above-else": (
 283        "#if FEATURE_B\nvoid active() {}\n// marker\n#else\nvoid fallback() {}\n#endif\n"
 284    ),
 285    "above-elif": (
 286        "#if FEATURE_A\nvoid a() {}\n// marker\n#elif FEATURE_B\nvoid b() {}\n#endif\n"
 287    ),
 288    "above-elifdef": (
 289        "#ifdef FEATURE_A\nvoid a() {}\n// marker\n#elifdef FEATURE_B\nvoid b() {}\n"
 290        "#endif\n"
 291    ),
 292    "above-endif": "#if FEATURE_B\nvoid active() {}\n// marker\n#endif\n",
 293    "above-endif-after-else": (
 294        "#if FEATURE_B\nvoid a() {}\n#else\nvoid b() {}\n// marker\n#endif\n"
 295    ),
 296}
 297
 298CONTAINERS = {
 299    "namespace": ("namespace lib {\n", "}\n"),
 300    "class": ("class Widget {\n", "};\n"),
 301}
 302
 303
 304@pytest.mark.parametrize("container", list(CONTAINERS))
 305@pytest.mark.parametrize(
 306    "body",
 307    [
 308        *(
 309            pytest.param(f"// marker\n{arms}", id=name)
 310            for name, arms in CONDITIONAL_ARMS.items()
 311        ),
 312        *(pytest.param(body, id=name) for name, body in ARM_ENDINGS.items()),
 313        pytest.param("/// marker\n" + CONDITIONAL_ARMS["one-arm"], id="doc-comment"),
 314        pytest.param(
 315            "// marker\n// and its second line\n" + CONDITIONAL_ARMS["one-arm"],
 316            id="comment-run",
 317        ),
 318        pytest.param(
 319            "/* marker\n */\n" + CONDITIONAL_ARMS["one-arm"], id="block-comment"
 320        ),
 321    ],
 322)
 323def test_marker_directly_above_a_conditional_is_on_nothing(
 324    container: str, body: str, init_cpp_tree_sitter: tuple[Parser, Query]
 325) -> None:
 326    """A marker directly above a preprocessor conditional's line, in a namespace or a
 327    class body, is attached to nothing, as clang attaches it: it would otherwise trace
 328    every unit of the container, whichever arm is compiled. ``#elif``, ``#else`` and
 329    ``#endif`` count as the opening directives do, and other comments may stand between
 330    when no blank line does."""
 331    head, tail = CONTAINERS[container]
 332
 333    assert _attached_to(head + body + tail, *init_cpp_tree_sitter) is ABOVE_CONDITIONAL
 334
 335
 336@pytest.mark.parametrize(
 337    "source",
 338    [
 339        pytest.param(
 340            "struct S {\n// marker\n" + CONDITIONAL_ARMS["one-arm"] + "};\n",
 341            id="struct",
 342        ),
 343        pytest.param(
 344            "union U {\n// marker\n#if FEATURE_B\nint a;\n#endif\nfloat b;\n};\n",
 345            id="union",
 346        ),
 347        pytest.param(
 348            "enum E {\n// marker\n#if FEATURE_B\nA,\n#endif\nB,\n};\n", id="enum"
 349        ),
 350        pytest.param(
 351            'namespace lib {\nextern "C" {\n// marker\n'
 352            + CONDITIONAL_ARMS["declaration-only"]
 353            + "}\n}\n",
 354            id="extern-c-block-in-a-namespace",
 355        ),
 356    ],
 357)
 358def test_marker_directly_above_a_conditional_is_on_nothing_in_any_body(
 359    source: str, init_cpp_tree_sitter: tuple[Parser, Query]
 360) -> None:
 361    """Not only in a namespace or class body: anywhere outside a function's body, a
 362    struct, union or enum body and an ``extern "C"`` block included, a marker directly
 363    above a conditional is attached to nothing, where the struct, union or enum, or
 364    the namespace around the block, held it."""
 365    assert _attached_to(source, *init_cpp_tree_sitter) is ABOVE_CONDITIONAL
 366
 367
 368@pytest.mark.parametrize(
 369    ("source", "expected"),
 370    [
 371        pytest.param(
 372            "// marker\n" + CONDITIONAL_ARMS["one-arm"],
 373            ABOVE_CONDITIONAL,
 374            id="file-level-first",
 375        ),
 376        pytest.param(
 377            "void first() {}\n// marker\n" + CONDITIONAL_ARMS["one-arm"],
 378            ABOVE_CONDITIONAL,
 379            id="file-level-after-an-item",
 380        ),
 381        pytest.param(
 382            "// marker\n\n" + CONDITIONAL_ARMS["one-arm"], None, id="file-header"
 383        ),
 384        pytest.param("void first() {}\n// marker\n", None, id="file-level-at-the-end"),
 385        pytest.param(
 386            "void outer() {\n// marker\n#if FEATURE_B\nfast();\n#endif\n}\n",
 387            ("function_definition", "void outer() {", "enclosing"),
 388            id="function-body",
 389        ),
 390        pytest.param(
 391            "namespace lib {\nvoid outer() {\n// marker\n#ifdef FEATURE_B\nfast();\n"
 392            "#endif\n}\n}\n",
 393            ("function_definition", "void outer() {", "enclosing"),
 394            id="function-body-in-a-namespace",
 395        ),
 396        pytest.param(
 397            "namespace lib {\nvoid first() {} // marker\n"
 398            + CONDITIONAL_ARMS["one-arm"]
 399            + "}\n",
 400            ("function_definition", "void first() {}", "trailing"),
 401            id="trailing",
 402        ),
 403        pytest.param(
 404            "namespace lib {\n// marker\n\n" + CONDITIONAL_ARMS["one-arm"] + "}\n",
 405            ("namespace_definition", "namespace lib {", "enclosing"),
 406            id="blank-line-namespace",
 407        ),
 408        pytest.param(
 409            "class Widget {\n// marker\n\n" + CONDITIONAL_ARMS["one-arm"] + "};\n",
 410            ("class_specifier", "class Widget {", "enclosing"),
 411            id="blank-line-class",
 412        ),
 413        pytest.param(
 414            "namespace lib {\n// marker\n// and its second line\n\n"
 415            + CONDITIONAL_ARMS["one-arm"]
 416            + "}\n",
 417            ("namespace_definition", "namespace lib {", "enclosing"),
 418            id="blank-line-after-a-comment-run",
 419        ),
 420        pytest.param(
 421            "namespace lib {\n#if FEATURE_B\nvoid active() {}\n// marker\n\n#endif\n}\n",
 422            ("namespace_definition", "namespace lib {", "enclosing"),
 423            id="blank-line-above-endif",
 424        ),
 425        pytest.param(
 426            "namespace lib {\n// marker\n#define FAST 1\nvoid active() {}\n}\n",
 427            ("namespace_definition", "namespace lib {", "enclosing"),
 428            id="above-define",
 429        ),
 430        pytest.param(
 431            'namespace lib {\n// marker\n#include "fast.h"\nvoid active() {}\n}\n',
 432            ("namespace_definition", "namespace lib {", "enclosing"),
 433            id="above-include",
 434        ),
 435        pytest.param(
 436            "class Widget {\n// marker\n#pragma pack(1)\nvoid active() {}\n};\n",
 437            ("class_specifier", "class Widget {", "enclosing"),
 438            id="above-pragma",
 439        ),
 440        pytest.param(
 441            "namespace lib {\n#if FEATURE_B\n// marker\nvoid active() {}\n#endif\n}\n",
 442            ("function_definition", "void active() {}", "leading"),
 443            id="inside-the-arm",
 444        ),
 445        pytest.param(
 446            "void outer() {\nstruct L {\n// marker\n"
 447            + CONDITIONAL_ARMS["one-arm"]
 448            + "};\n}\n",
 449            ("struct_specifier", "struct L {", "enclosing"),
 450            id="class-local-to-a-function",
 451        ),
 452        pytest.param(
 453            "namespace lib {\nauto l = []() {\n// marker\n#if FEATURE_B\nactive();\n"
 454            "#endif\n};\nvoid after() {}\n}\n",
 455            ("namespace_definition", "namespace lib {", "enclosing"),
 456            id="lambda-in-a-namespace",
 457        ),
 458        pytest.param(
 459            "namespace lib {\n// marker\n%:if FEATURE_B\nvoid active() {}\n%:endif\n}\n",
 460            ("namespace_definition", "namespace lib {", "enclosing"),
 461            id="digraph-if",
 462        ),
 463        pytest.param(
 464            "namespace lib {\n#if FEATURE_B\nvoid active() {}\n// marker\n%:endif\n}\n",
 465            ("namespace_definition", "namespace lib {", "enclosing"),
 466            id="digraph-endif",
 467        ),
 468        pytest.param(
 469            "namespace lib {\n#define F(x) \\\n  /* marker */ \\\n  # if x\n"
 470            "void active() {}\n}\n",
 471            ("namespace_definition", "namespace lib {", "enclosing"),
 472            id="define-continuation",
 473        ),
 474    ],
 475)
 476def test_marker_near_a_conditional_keeps_its_attachment(
 477    source: str,
 478    expected: tuple[str, str, str] | utils.Unattached | None,
 479    init_cpp_tree_sitter: tuple[Parser, Query],
 480) -> None:
 481    """Around a preprocessor conditional, every other marker keeps the attachment it
 482    has: at file level it is on nothing anyway, by rule 7 when directly above the
 483    conditional and by another rule (a file header, nothing after it) otherwise; in a function's body it is the
 484    function's; a trailing marker is its row's entity's; a blank line between makes it
 485    a comment on the namespace or class as a whole; any other directive (``#define``,
 486    ``#include``, ``#pragma``) breaks the attachment to the next entity as any code
 487    does; and inside the arm, the marker leads the definition, which is the remedy.
 488
 489    A lambda's body and a class defined in a function are a function's body too. And
 490    the directive is read as the grammar reads it: a digraph (``%:if``) is no
 491    conditional to it, nor is a ``# if`` line that continues a ``#define``."""
 492    assert _attached_to(source, *init_cpp_tree_sitter) == expected
 493
 494
 495@pytest.mark.parametrize(
 496    ("code", "result"),
 497    [
 498        (
 499            b"""
 500                def dummy_func1():
 501                    # @req-id: need_001
 502                    pass
 503            """,
 504            "def dummy_func1()",
 505        ),
 506        (
 507            b"""
 508                def dummy_func1():
 509                    # @req-id: need_002
 510                    def dummy_func2():
 511                        pass
 512                    pass
 513            """,
 514            "def dummy_func2()",
 515        ),
 516        (
 517            b"""
 518                def dummy_func1():
 519                '''@req-id: need_002'''
 520                    def nested_dummy_func():
 521                        pass
 522                    pass
 523            """,
 524            "def dummy_func1()",
 525        ),
 526        (
 527            b"""
 528                def dummy_func1():
 529                    def nested_dummy_func():
 530                        '''@req-id: need_002'''
 531                        pass
 532                    pass
 533            """,
 534            "def nested_dummy_func()",
 535        ),
 536        (
 537            b"""
 538                def dummy_func1():
 539                    def nested_dummy_func():
 540                        # @req-id: need_002
 541                        pass
 542                    pass
 543            """,
 544            "def nested_dummy_func()",
 545        ),
 546        (
 547            b"""
 548                def dummy_func1():
 549                    def nested_dummy_func():
 550                        pass
 551                    # @req-id: need_002
 552                    pass
 553            """,
 554            "def dummy_func1()",
 555        ),
 556    ],
 557)
 558def test_find_associated_scope_python(code, result, init_python_tree_sitter):
 559    parser, query = init_python_tree_sitter
 560    comments = utils.extract_comments(code, parser, query)
 561    node: TreeSitterNode | None = utils.find_associated_scope(
 562        comments[0], CommentType.python
 563    )
 564    assert node
 565    assert node.text
 566    func_def = node.text.decode("utf-8")
 567    assert func_def.startswith(result)
 568
 569
 570@pytest.mark.parametrize(
 571    ("code", "result"),
 572    [
 573        (
 574            b"""
 575                // @req-id: need_001
 576                public class DummyClass1
 577                {
 578                }
 579            """,
 580            "public class DummyClass1",
 581        ),
 582        (
 583            b"""
 584                public class DummyClass2
 585                {
 586                    // @req-id: need_001
 587                    public void DummyFunc2()
 588                    {
 589                    }
 590                }
 591            """,
 592            "public void DummyFunc2",
 593        ),
 594        (
 595            b"""
 596                public class DummyClass3
 597                {
 598                    // @req-id: need_001
 599                    public string Property1 { get; set; }
 600                }
 601            """,
 602            "public string Property1",
 603        ),
 604    ],
 605)
 606def test_find_associated_scope_csharp(code, result, init_csharp_tree_sitter):
 607    parser, query = init_csharp_tree_sitter
 608    comments = utils.extract_comments(code, parser, query)
 609    node: TreeSitterNode | None = utils.find_associated_scope(
 610        comments[0], CommentType.cs
 611    )
 612    assert node
 613    assert node.text
 614    func_def = node.text.decode("utf-8")
 615    assert func_def.startswith(result)
 616
 617
 618@pytest.mark.parametrize(
 619    ("code", "result"),
 620    [
 621        (
 622            b"""
 623                # @req-id: need_001
 624                database:
 625                  host: localhost
 626                  port: 5432
 627            """,
 628            "database:",
 629        ),
 630        (
 631            b"""
 632                services:
 633                  web:
 634                    # @req-id: need_002
 635                    image: nginx:latest
 636                    ports:
 637                      - "80:80"
 638            """,
 639            "image: nginx:latest",
 640        ),
 641        (
 642            b"""
 643                # @req-id: need_003
 644                version: "3.8"
 645                services:
 646                  app:
 647                    build: .
 648            """,
 649            "version:",
 650        ),
 651        (
 652            b"""
 653                items:
 654                  # @req-id: need_004
 655                  - name: item1
 656                    value: test
 657                  - name: item2
 658                    value: test2
 659            """,
 660            "- name: item1",
 661        ),
 662    ],
 663)
 664def test_find_associated_scope_yaml(code, result, init_yaml_tree_sitter):
 665    parser, query = init_yaml_tree_sitter
 666    comments = utils.extract_comments(code, parser, query)
 667    node: TreeSitterNode | None = utils.find_associated_scope(
 668        comments[0], CommentType.yaml
 669    )
 670    assert node
 671    assert node.text
 672    yaml_structure = node.text.decode("utf-8")
 673    assert result in yaml_structure
 674
 675
 676@pytest.mark.parametrize(
 677    ("code", "result"),
 678    [
 679        (
 680            b"""
 681                // @req-id: need_001
 682                fn dummy_func1() {
 683                }
 684            """,
 685            "fn dummy_func1()",
 686        ),
 687        (
 688            b"""
 689                fn dummy_func2() {
 690                }
 691                // @req-id: need_001
 692                fn dummy_func1() {
 693                }
 694            """,
 695            "fn dummy_func1()",
 696        ),
 697        (
 698            b"""
 699                fn dummy_func1() {
 700                    let a = 1;
 701                    /* @req-id: need_001 */
 702                }
 703            """,
 704            "fn dummy_func1()",
 705        ),
 706        (
 707            b"""
 708                fn dummy_func1() {
 709                    // @req-id: need_001
 710                    let a = 1;
 711                }
 712                fn dummy_func2() {
 713                }
 714            """,
 715            "fn dummy_func1()",
 716        ),
 717        (
 718            b"""
 719                /// @req-id: need_001
 720                fn dummy_func1() {
 721                }
 722            """,
 723            "fn dummy_func1()",
 724        ),
 725        (
 726            b"""
 727                struct MyStruct {
 728                    // @req-id: need_001
 729                    field: i32,
 730                }
 731            """,
 732            "struct MyStruct",
 733        ),
 734    ],
 735)
 736def test_find_associated_scope_rust(code, result, init_rust_tree_sitter):
 737    parser, query = init_rust_tree_sitter
 738    comments = utils.extract_comments(code, parser, query)
 739    node: TreeSitterNode | None = utils.find_associated_scope(
 740        comments[0], CommentType.rust
 741    )
 742    assert node
 743    assert node.text
 744    rust_def = node.text.decode("utf-8")
 745    assert result in rust_def
 746
 747
 748@pytest.mark.parametrize(
 749    ("code", "result"),
 750    [
 751        # leading comment is associated with the following key/value pair
 752        (
 753            b'{\n  // @req-id: need_001\n  "alpha": 1\n}\n',
 754            '"alpha": 1',
 755        ),
 756        # inline comment is associated with the array item on the same row
 757        (
 758            b'{\n  "items": [\n    "first", // @req-id: need_001\n    "second"\n  ]\n}\n',
 759            '"first"',
 760        ),
 761        # inline comment is associated with the pair on the same row
 762        (
 763            b'{\n  "alpha": 1, // @req-id: need_001\n  "beta": 2\n}\n',
 764            '"alpha": 1',
 765        ),
 766        # block comment is associated with the following pair
 767        (
 768            b'{\n  /* @req-id: need_001 */\n  "beta": 2\n}\n',
 769            '"beta": 2',
 770        ),
 771        # trailing comment falls back to the enclosing object
 772        (
 773            b'{\n  "alpha": 1\n  // @req-id: need_001\n}\n',
 774            '"alpha"',
 775        ),
 776    ],
 777)
 778def test_find_associated_scope_jsonc(code, result, init_jsonc_tree_sitter):
 779    parser, query = init_jsonc_tree_sitter
 780    comments = utils.extract_comments(code, parser, query)
 781    node: TreeSitterNode | None = utils.find_associated_scope(
 782        comments[0], CommentType.jsonc
 783    )
 784    assert node
 785    assert node.text
 786    jsonc_structure = node.text.decode("utf-8")
 787    assert result in jsonc_structure
 788
 789
 790@pytest.mark.parametrize(
 791    ("code", "result"),
 792    [
 793        # comment above a POSIX-style function definition
 794        (
 795            b"""
 796                # @req-id: need_001
 797                greet() {
 798                    echo hi
 799                }
 800            """,
 801            "greet()",
 802        ),
 803        # comment above the `function` keyword form
 804        (
 805            b"""
 806                # @req-id: need_002
 807                function greet {
 808                    echo hi
 809                }
 810            """,
 811            "function greet",
 812        ),
 813        # comment inside a function body falls back to the enclosing function
 814        (
 815            b"""
 816                greet() {
 817                    # @req-id: need_003
 818                    echo hi
 819                }
 820            """,
 821            "greet()",
 822        ),
 823    ],
 824)
 825def test_find_associated_scope_bash(code, result, init_bash_tree_sitter):
 826    parser, query = init_bash_tree_sitter
 827    comments = utils.extract_comments(code, parser, query)
 828    node: TreeSitterNode | None = utils.find_associated_scope(
 829        comments[0], CommentType.bash
 830    )
 831    assert node
 832    assert node.text
 833    func_def = node.text.decode("utf-8")
 834    assert func_def.startswith(result)
 835
 836
 837@pytest.mark.parametrize(
 838    ("code", "result"),
 839    [
 840        # comment above a function
 841        (
 842            b"""# @req-id: need_001
 843function Start-Px {
 844    param([string] $Config)
 845}
 846""",
 847            "function Start-Px",
 848        ),
 849        # comment above a filter, PowerShell's pipeline function
 850        (b"# @req-id: need_002\nfilter Get-Odd { $_ }\n", "filter Get-Odd"),
 851        # comment after a function body's last statement falls back to the function
 852        (
 853            b"""function Start-Px {
 854    Write-Host 'start'
 855    # @req-id: need_003
 856}
 857""",
 858            "function Start-Px",
 859        ),
 860        # comment above a class
 861        (b"# @req-id: need_004\nclass Proxy { }\n", "class Proxy"),
 862        # comment above a method inside a class
 863        (
 864            b"""class Proxy {
 865    # @req-id: need_005
 866    [string] Start([string] $c) { return $c }
 867}
 868""",
 869            "[string] Start",
 870        ),
 871        # comment above a Pester It inside a Describe
 872        (
 873            b"""Describe 'px' {
 874    # @req-id: need_006
 875    It 'starts' {
 876        1 | Should -Be 1
 877    }
 878}
 879""",
 880            "It 'starts'",
 881        ),
 882        # PowerShell is case-insensitive, and so are Pester's block names
 883        (
 884            b"""describe 'px' {
 885    # @req-id: need_007
 886    it 'lower' { }
 887}
 888""",
 889            "it 'lower'",
 890        ),
 891        # comment above a Describe
 892        (b"# @req-id: need_008\nDescribe 'px' { }\n", "Describe 'px'"),
 893        # comment inside an It body falls back to the It
 894        (
 895            b"""Describe 'px' {
 896    It 'starts' {
 897        1 | Should -Be 1
 898        # @req-id: need_009
 899    }
 900}
 901""",
 902            "It 'starts'",
 903        ),
 904        # a plain command after the comment is code: it belongs to the Describe it is
 905        # in, not to the It past the command, as in every other language
 906        (
 907            b"""Describe 'px' {
 908    # @req-id: need_012
 909    BeforeAll { $x = 1 }
 910    It 'a' { }
 911}
 912""",
 913            "Describe 'px'",
 914        ),
 915        # a Context is a scope like an It
 916        (
 917            b"""Describe 'px' {
 918    # @req-id: need_013
 919    Context 'ctx' { }
 920}
 921""",
 922            "Context 'ctx'",
 923        ),
 924        # a block called through Pester's module qualifier is a Pester block too
 925        (
 926            b"""Describe 'px' {
 927    # @req-id: need_014
 928    Pester\\It 'qualified' { }
 929}
 930""",
 931            "Pester\\It 'qualified'",
 932        ),
 933        # comment above a property inside a class
 934        (
 935            b"""class Proxy {
 936    # @req-id: need_015
 937    [string] $Name
 938    [void] Start() { }
 939}
 940""",
 941            "[string] $Name",
 942        ),
 943        # comment above an enum
 944        (b"# @req-id: need_016\nenum Mode { Direct }\n", "enum Mode"),
 945    ],
 946)
 947def test_find_associated_scope_powershell(code, result, init_powershell_tree_sitter):
 948    parser, query = init_powershell_tree_sitter
 949    comments = utils.extract_comments(code, parser, query)
 950    assert comments
 951    node: TreeSitterNode | None = utils.find_associated_scope(
 952        comments[0], CommentType.powershell
 953    )
 954    assert node
 955    assert node.text
 956    assert node.text.decode("utf-8").startswith(result)
 957
 958
 959@pytest.mark.parametrize(
 960    "command",
 961    [
 962        b"Write-Host 'x'",
 963        # an It of another module is not Pester's
 964        b"Other\\It 'x' { }",
 965        # a plain command is code: the function past it is not the marker's
 966        b"Set-StrictMode -Version Latest\nfunction Start-Px { }",
 967    ],
 968)
 969def test_a_non_pester_command_is_no_scope(command, init_powershell_tree_sitter):
 970    """Only Pester's blocks are scopes among commands: a marker above any other
 971    command, at file level, binds to nothing, and not to an entity after it."""
 972    parser, query = init_powershell_tree_sitter
 973    comments = utils.extract_comments(
 974        b"# @req-id: need_010\n" + command + b"\n", parser, query
 975    )
 976    assert comments
 977    assert utils.find_associated_scope(comments[0], CommentType.powershell) is None
 978
 979
 980def _powershell_attached(
 981    source: str, parser: Parser, query: Query
 982) -> tuple[str, str] | None:
 983    """The first line of the entity the comment holding ``marker`` in the PowerShell
 984    ``source`` is attached to, and the mechanism; ``None`` for nothing."""
 985    comments = utils.extract_comments(source.encode(), parser, query) or []
 986    marker = next(c for c in comments if b"marker" in (c.text or b""))
 987    association = utils.associate(marker, CommentType.powershell)
 988    if association is None or isinstance(association, utils.Unattached):
 989        return None
 990    first_line = (association.node.text or b"").decode().splitlines()[0]
 991    return first_line.strip(), association.mechanism
 992
 993
 994@pytest.mark.parametrize(
 995    ("source", "expected"),
 996    [
 997        pytest.param(
 998            "function Outer {\n    <#\n    .SYNOPSIS\n    marker\n    #>\n"
 999            "    function Inner { }\n}\n",
1000            ("function Outer {", "doc_comment"),
1001            id="help-block-before-a-nested-function",
1002        ),
1003        pytest.param(
1004            "function Outer {\n    <# marker\n    .synopsis #>\n    [CmdletBinding()]\n"
1005            "    param()\n    Get-X\n}\n",
1006            ("function Outer {", "doc_comment"),
1007            id="help-block-before-param-lower-case-keyword",
1008        ),
1009        pytest.param(
1010            "filter Outer {\n    <# .DESCRIPTION\n    marker #>\n    Get-X\n}\n",
1011            ("filter Outer {", "doc_comment"),
1012            id="help-block-in-a-filter",
1013        ),
1014        pytest.param(
1015            "function Outer { <# marker\n    .SYNOPSIS #> Get-X }\n",
1016            ("function Outer { <# marker", "doc_comment"),
1017            id="help-block-on-the-function-row",
1018        ),
1019        pytest.param(
1020            "function Outer {\n    # .SYNOPSIS\n    #   Does it.\n    # marker\n"
1021            "    function Inner { }\n}\n",
1022            ("function Outer {", "doc_comment"),
1023            id="help-hash-run-keyword-above-the-marker",
1024        ),
1025        pytest.param(
1026            "function Outer {\n    # marker\n    # .EXAMPLE\n    function Inner { }\n}\n",
1027            ("function Outer {", "doc_comment"),
1028            id="help-hash-run-keyword-below-the-marker",
1029        ),
1030        # a plain comment first in a body keeps the rule of every language
1031        pytest.param(
1032            "function Outer {\n    # marker\n    function Inner { }\n}\n",
1033            ("function Inner { }", "leading"),
1034            id="plain-comment-before-a-nested-function",
1035        ),
1036        pytest.param(
1037            "function Outer {\n    <# marker #>\n    function Inner { }\n}\n",
1038            ("function Inner { }", "leading"),
1039            id="plain-block-before-a-nested-function",
1040        ),
1041        # a keyword that does not open a line is no help keyword
1042        pytest.param(
1043            "function Outer {\n    <# see .SYNOPSIS marker #>\n    function Inner { }\n}\n",
1044            ("function Inner { }", "leading"),
1045            id="keyword-inside-a-line",
1046        ),
1047        # a blank line ends a run of # comments: the help is another run
1048        pytest.param(
1049            "function Outer {\n    # .SYNOPSIS\n\n    # marker\n    function Inner { }\n}\n",
1050            ("function Inner { }", "leading"),
1051            id="hash-run-broken-by-a-blank-line",
1052        ),
1053        # help after a statement is not first in the body
1054        pytest.param(
1055            "function Outer {\n    $x = 1\n    <# .SYNOPSIS\n    marker #>\n"
1056            "    function Inner { }\n}\n",
1057            ("function Inner { }", "leading"),
1058            id="help-after-a-statement",
1059        ),
1060        # only a function's body: a script block's help is a plain comment
1061        pytest.param(
1062            "Describe 'd' {\n    <# .SYNOPSIS\n    marker #>\n    It 'a' { }\n}\n",
1063            ("It 'a' { }", "leading"),
1064            id="help-in-a-pester-block",
1065        ),
1066        # help before the function binds it as any comment does
1067        pytest.param(
1068            "<#\n.SYNOPSIS\nmarker\n#>\nfunction Outer { }\n",
1069            ("function Outer { }", "leading"),
1070            id="help-before-the-function",
1071        ),
1072        # help at the end of a body is inside it
1073        pytest.param(
1074            "function Outer {\n    Get-X\n    <# .SYNOPSIS\n    marker #>\n}\n",
1075            ("function Outer {", "enclosing"),
1076            id="help-at-the-end-of-the-body",
1077        ),
1078        # a keyword line holds the keyword alone, or with its one argument
1079        pytest.param(
1080            "function Outer {\n    # .NOTES: a detail, not help\n    # marker\n"
1081            "    function Inner { }\n}\n",
1082            ("function Inner { }", "leading"),
1083            id="keyword-followed-by-a-colon",
1084        ),
1085        pytest.param(
1086            "function Outer {\n    #   .Description = $x.Description\n    # marker\n"
1087            "    function Inner { }\n}\n",
1088            ("function Inner { }", "leading"),
1089            id="commented-out-assignment",
1090        ),
1091        pytest.param(
1092            "function Outer {\n    <# .SYNOPSIS Starts it.\n    marker #>\n"
1093            "    function Inner { }\n}\n",
1094            ("function Inner { }", "leading"),
1095            id="argument-to-a-keyword-that-takes-none",
1096        ),
1097        pytest.param(
1098            "function Outer {\n    <# .PARAMETER Path Extra\n    marker #>\n"
1099            "    function Inner { }\n}\n",
1100            ("function Inner { }", "leading"),
1101            id="two-arguments",
1102        ),
1103        pytest.param(
1104            "function Outer {\n    <#\n      .Parameter Path  \n    marker #>\n"
1105            "    function Inner { }\n}\n",
1106            ("function Outer {", "doc_comment"),
1107            id="parameter-with-its-argument",
1108        ),
1109    ],
1110)
1111def test_powershell_help_first_in_a_body_documents_the_function(
1112    source, expected, init_powershell_tree_sitter
1113):
1114    """Comment-based help (a ``<# #>`` block, or a run of ``#`` lines, holding a help
1115    keyword line such as ``.SYNOPSIS``) first in a function's body documents that
1116    function: a marker in it is the function's
1117    documentation comment, even when a nested function follows. A plain comment there
1118    keeps the rule of every language, and binds the next statement's entity."""
1119    parser, query = init_powershell_tree_sitter
1120    assert _powershell_attached(source, parser, query) == expected
1121
1122
1123@pytest.mark.parametrize(
1124    ("source", "expected"),
1125    [
1126        pytest.param(
1127            "# marker\n[DscResource()]\nclass K { }\n",
1128            None,
1129            id="above-attribute-at-file-start",
1130        ),
1131        pytest.param(
1132            "function a { }\n# marker\n[DscResource()]\nclass K { }\n",
1133            None,
1134            id="above-attribute-after-code",
1135        ),
1136        pytest.param(
1137            'function a { }\n# marker\n[SuppressMessage("A", "")]\nclass K { }\n',
1138            None,
1139            id="above-attribute-with-arguments",
1140        ),
1141        pytest.param(
1142            "function a { }\n[Flags()]\nenum E { # marker\n    A = 1\n}\n",
1143            ("enum E { # marker", "trailing"),
1144            id="trailing-the-enum-line",
1145        ),
1146        pytest.param(
1147            "function a { }\n[DscResource()]\nclass K { # marker\n}\n",
1148            ("class K { # marker", "trailing"),
1149            id="trailing-the-class-line",
1150        ),
1151    ],
1152)
1153def test_a_marker_above_a_powershell_attribute_binds_nothing(
1154    source, expected, init_powershell_tree_sitter
1155):
1156    """The grammar cannot read an attribute before a class or an enum: what it makes of
1157    one depends on the code around it (an ``ERROR`` node, a type literal statement
1158    holding one, a cast that takes the class in), so a marker above the attribute binds
1159    nothing, as the documentation says. A marker trailing the class's or the enum's
1160    line, with the attribute on a line of its own, binds it. A grammar that learns
1161    attributes fails this test: then the documented limitation goes."""
1162    parser, query = init_powershell_tree_sitter
1163    assert _powershell_attached(source, parser, query) == expected
1164
1165
1166@pytest.mark.parametrize(
1167    ("source", "expected"),
1168    [
1169        pytest.param(
1170            "function f { } # marker\n",
1171            ("function f { }", "trailing"),
1172            id="function",
1173        ),
1174        pytest.param(
1175            "Describe 'd' {\n    It 'x' { } # marker\n}\n",
1176            ("It 'x' { }", "trailing"),
1177            id="it-in-describe",
1178        ),
1179        pytest.param(
1180            "Describe 'top' { } # marker\nfunction f { }\n",
1181            ("Describe 'top' { }", "trailing"),
1182            id="describe-at-file-level",
1183        ),
1184        pytest.param(
1185            "Describe 'd' {\n    Context 'c' {\n    } # marker\n}\n",
1186            ("Context 'c' {", "trailing"),
1187            id="context-closing-on-the-row",
1188        ),
1189        pytest.param(
1190            "Set-StrictMode -Version 3 # marker\nfunction f { }\n",
1191            None,
1192            id="plain-command",
1193        ),
1194        pytest.param(
1195            "Describe 'd' {\n    Write-Host 'x' # marker\n}\n",
1196            ("Describe 'd' {", "enclosing"),
1197            id="plain-command-in-describe",
1198        ),
1199    ],
1200)
1201def test_a_trailing_powershell_marker_binds_the_entity_on_its_row(
1202    source, expected, init_powershell_tree_sitter
1203):
1204    """A trailing comment belongs to the entity that ends on its row, a Pester block
1205    included, which the grammar wraps in a pipeline; on a plain command's row it falls
1206    back to the enclosing entity, as in every other language."""
1207    parser, query = init_powershell_tree_sitter
1208    assert _powershell_attached(source, parser, query) == expected
1209
1210
1211@pytest.mark.parametrize(
1212    ("code", "result"),
1213    [
1214        (
1215            b"""
1216                // @req-id: need_001
1217                function dummyFunc1() {
1218                }
1219            """,
1220            "function dummyFunc1()",
1221        ),
1222        (
1223            b"""
1224                class DummyClass {
1225                    // @req-id: need_001
1226                    method1() {
1227                    }
1228                }
1229            """,
1230            "method1()",
1231        ),
1232        # leading comment on an exported function must descend into
1233        # export_statement, not resolve to no scope
1234        (
1235            b"""
1236                // @req-id: need_001
1237                export function dummyFunc1() {
1238                }
1239            """,
1240            "function dummyFunc1()",
1241        ),
1242        # leading comment on an exported class
1243        (
1244            b"""
1245                // @req-id: need_001
1246                export class DummyClass {
1247                }
1248            """,
1249            "class DummyClass",
1250        ),
1251        # leading comment on an exported const arrow function
1252        (
1253            b"""
1254                // @req-id: need_001
1255                export const dummyFunc1 = () => {
1256                };
1257            """,
1258            "dummyFunc1",
1259        ),
1260        # arrow-function const still resolves to itself
1261        (
1262            b"""
1263                // @req-id: need_001
1264                const dummyFunc1 = () => {
1265                };
1266            """,
1267            "dummyFunc1",
1268        ),
1269        # JSX-returning component parses cleanly and resolves scope (.tsx content)
1270        (
1271            b"""
1272                // @req-id: need_001
1273                export function Button() {
1274                    return <button>Click me</button>;
1275                }
1276            """,
1277            "function Button()",
1278        ),
1279    ],
1280)
1281def test_find_associated_scope_typescript(code, result, init_typescript_tree_sitter):
1282    parser, query = init_typescript_tree_sitter
1283    comments = utils.extract_comments(code, parser, query)
1284    node: TreeSitterNode | None = utils.find_associated_scope(
1285        comments[0], CommentType.ts
1286    )
1287    assert node
1288    assert node.text
1289    ts_def = node.text.decode("utf-8")
1290    assert result in ts_def
1291
1292
1293@pytest.mark.parametrize(
1294    ("code", "result"),
1295    [
1296        (
1297            b"""
1298                def dummy_func1():
1299                    # @req-id: need_001
1300                    pass
1301            """,
1302            "def dummy_func1()",
1303        ),
1304        (
1305            b"""
1306                def dummy_func1():
1307                '''@req-id: need_001'''
1308                    pass
1309            """,
1310            "def dummy_func1()",
1311        ),
1312        (
1313            b"""
1314                def dummy_func1():
1315                    def nested_dummy_func1():
1316                        '''@req-id: need_001'''
1317                        pass
1318                    pass
1319            """,
1320            "def nested_dummy_func1()",
1321        ),
1322        (
1323            b"""
1324                def dummy_func1():
1325                    '''@req-id: need_001'''
1326                    def nested_dummy_func1():
1327                        pass
1328                    pass
1329            """,
1330            "def dummy_func1()",
1331        ),
1332    ],
1333)
1334def test_find_enclosing_scope_python(code, result, init_python_tree_sitter):
1335    parser, query = init_python_tree_sitter
1336    comments = utils.extract_comments(code, parser, query)
1337    node: TreeSitterNode | None = utils.find_enclosing_scope(
1338        comments[0], CommentType.python
1339    )
1340    assert node
1341    assert node.text
1342    func_def = node.text.decode("utf-8")
1343    assert result in func_def
1344
1345
1346@pytest.mark.parametrize(
1347    ("code", "result"),
1348    [
1349        (
1350            b"""
1351                # @req-id: need_001
1352                def dummy_func1():
1353                    pass
1354            """,
1355            "def dummy_func1()",
1356        ),
1357        (
1358            b"""
1359                # @req-id: need_001
1360                # @req-id: need_002
1361                def dummy_func1():
1362                    pass
1363            """,
1364            "def dummy_func1()",
1365        ),
1366    ],
1367)
1368def test_find_next_scope_python(code, result, init_python_tree_sitter):
1369    parser, query = init_python_tree_sitter
1370    comments = utils.extract_comments(code, parser, query)
1371    node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.python)
1372    assert node
1373    assert node.text
1374    func_def = node.text.decode("utf-8")
1375    assert result in func_def
1376
1377
1378@pytest.mark.parametrize(
1379    ("code", "result"),
1380    [
1381        (
1382            b"""
1383                // @req-id: need_001
1384                void dummy_func1(){
1385                }
1386            """,
1387            "void dummy_func1()",
1388        ),
1389        (
1390            b"""
1391                /* @req-id: need_001 */
1392                void dummy_func1(){
1393                }
1394            """,
1395            "void dummy_func1()",
1396        ),
1397    ],
1398)
1399def test_find_next_scope_cpp(code, result, init_cpp_tree_sitter):
1400    parser, query = init_cpp_tree_sitter
1401    comments = utils.extract_comments(code, parser, query)
1402    node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.cpp)
1403    assert node
1404    assert node.text
1405    func_def = node.text.decode("utf-8")
1406    assert result in func_def
1407
1408
1409@pytest.mark.parametrize(
1410    ("code", "result"),
1411    [
1412        (
1413            b"""
1414                // @req-id: need_001
1415                public class DummyClass1
1416                {
1417                }
1418            """,
1419            "public class DummyClass1",
1420        ),
1421        (
1422            b"""
1423
1424                public class DummyClass1
1425                {
1426                    /* @req-id: need_001 */
1427                    /* @req-id: need_002 */
1428                    public void DummyFunc1()
1429                    {
1430                    }
1431                }
1432            """,
1433            "public void DummyFunc1",
1434        ),
1435    ],
1436)
1437def test_find_next_scope_csharp(code, result, init_csharp_tree_sitter):
1438    parser, query = init_csharp_tree_sitter
1439    comments = utils.extract_comments(code, parser, query)
1440    node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.cs)
1441    assert node
1442    assert node.text
1443    func_def = node.text.decode("utf-8")
1444    assert result in func_def
1445
1446
1447@pytest.mark.parametrize(
1448    ("code", "result"),
1449    [
1450        (
1451            b"""
1452                // @req-id: need_001
1453                function dummyFunc1() {
1454                }
1455            """,
1456            "function dummyFunc1()",
1457        ),
1458        (
1459            b"""
1460                // @req-id: need_001
1461                export function dummyFunc1() {
1462                }
1463            """,
1464            "function dummyFunc1()",
1465        ),
1466    ],
1467)
1468def test_find_next_scope_typescript(code, result, init_typescript_tree_sitter):
1469    parser, query = init_typescript_tree_sitter
1470    comments = utils.extract_comments(code, parser, query)
1471    node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.ts)
1472    assert node
1473    assert node.text
1474    func_def = node.text.decode("utf-8")
1475    assert result in func_def
1476
1477
1478def test_a_plain_const_breaks_adjacency_typescript(init_typescript_tree_sitter):
1479    """A plain (non-function) const between the comment and a function is code, not
1480    trivia: the comment is attached to neither, here to the file."""
1481    code = b"""
1482        // @req-id: need_001
1483        const helperFlag = true;
1484        function dummyFunc1() {
1485        }
1486    """
1487    parser, query = init_typescript_tree_sitter
1488    comments = utils.extract_comments(code, parser, query)
1489    assert comments
1490    assert utils.find_next_scope(comments[0], CommentType.ts) is None
1491    assert utils.find_associated_scope(comments[0], CommentType.ts) is None
1492
1493
1494def test_find_associated_scope_typescript_jsx_no_parse_error(
1495    init_typescript_tree_sitter,
1496):
1497    """A JSX-returning component must parse cleanly under the TSX grammar."""
1498    code = b"""
1499        // @req-id: need_001
1500        export function Button() {
1501            return <button>Click me</button>;
1502        }
1503    """
1504    parser, _ = init_typescript_tree_sitter
1505    tree = parser.parse(code)
1506    assert not tree.root_node.has_error
1507
1508
1509@pytest.mark.parametrize(
1510    ("name", "grammar"),
1511    [
1512        ("a.ts", "typescript"),
1513        ("a.mts", "typescript"),
1514        ("a.cts", "typescript"),
1515        ("A.TS", "typescript"),
1516        ("a.Mts", "typescript"),
1517        # a declaration file's suffix is ``.ts``: TypeScript, as any ``.ts`` file
1518        ("a.d.ts", "typescript"),
1519        ("a.tsx", "tsx"),
1520        ("a.js", "tsx"),
1521        ("a.jsx", "tsx"),
1522        ("a.mjs", "tsx"),
1523        ("a.cjs", "tsx"),
1524    ],
1525)
1526def test_ts_grammar_key(name: str, grammar: str) -> None:
1527    """TypeScript's own suffixes, in any case, get the TypeScript grammar, where a
1528    ``<T>x`` cast is legal; every other suffix of the family gets TSX."""
1529    assert utils.ts_grammar_key(Path(name)) == grammar
1530
1531
1532def test_typescript_ts_suffix_recovers_markers_around_angle_bracket_cast():
1533    """A ``.ts`` file must use the plain TypeScript grammar, not TSX.
1534
1535    ``<string>x`` is a legacy angle-bracket type assertion: valid TypeScript
1536    syntax, but JSX syntax under the TSX grammar. There it parses as a
1537    ``jsx_opening_element`` and swallows the rest of the file into a single
1538    ``jsx_text`` node, silently dropping every marker after it (``has_error``
1539    is also set). ``init_tree_sitter`` must pick the plain TypeScript grammar
1540    for a ``.ts`` path, via ``ts_grammar_key``, so markers both above and
1541    below the cast all survive.
1542    """
1543    code = b"""// @Top, IMPL_TOP
1544const v = <string>x;
1545// @Bottom, IMPL_BOTTOM
1546const y = 2;
1547// @Third, IMPL_THIRD
1548"""
1549    parser, query = utils.init_tree_sitter(CommentType.ts, Path("dummy.ts"))
1550    tree = parser.parse(code)
1551    assert not tree.root_node.has_error
1552
1553    comments = utils.extract_comments(code, parser, query)
1554    assert comments is not None
1555    comments.sort(key=lambda node: node.start_point.row)
1556    texts = [node.text.decode("utf-8") for node in comments if node.text]
1557    assert texts == [
1558        "// @Top, IMPL_TOP",
1559        "// @Bottom, IMPL_BOTTOM",
1560        "// @Third, IMPL_THIRD",
1561    ]
1562
1563
1564@pytest.mark.parametrize(
1565    ("code", "result"),
1566    [
1567        (
1568            b"""
1569                void dummy_func1(){
1570                // @req-id: need_001
1571                }
1572            """,
1573            "void dummy_func1()",
1574        ),
1575        (
1576            b"""
1577                void dummy_func1(){
1578                /* @req-id: need_001 */
1579                }
1580            """,
1581            "void dummy_func1()",
1582        ),
1583    ],
1584)
1585def test_find_enclosing_scope_cpp(code, result, init_cpp_tree_sitter):
1586    parser, query = init_cpp_tree_sitter
1587    comments = utils.extract_comments(code, parser, query)
1588    node: TreeSitterNode | None = utils.find_enclosing_scope(
1589        comments[0], CommentType.cpp
1590    )
1591    assert node
1592    assert node.text
1593    func_def = node.text.decode("utf-8")
1594    assert result in func_def
1595
1596
1597@pytest.mark.parametrize(
1598    ("code", "result"),
1599    [
1600        (
1601            b"""
1602                public class DummyClass1
1603                {
1604                    // @req-id: need_001
1605                }
1606            """,
1607            "public class DummyClass1",
1608        ),
1609        (
1610            b"""
1611                public class DummyClass1
1612                {
1613                    public void DummyFunc1()
1614                    {
1615                        /* @req-id: need_001 */
1616                    }
1617                }
1618            """,
1619            "public void DummyFunc1()",
1620        ),
1621        (
1622            b"""
1623                public class DummyClass1
1624                {
1625                    public string DummyProperty1
1626                    {
1627                        get
1628                        {
1629                            /* @req-id: need_001 */
1630                            return "dummy";
1631                        }
1632                    }
1633                }
1634            """,
1635            "public string DummyProperty1",
1636        ),
1637    ],
1638)
1639def test_find_enclosing_scope_csharp(code, result, init_csharp_tree_sitter):
1640    parser, query = init_csharp_tree_sitter
1641    comments = utils.extract_comments(code, parser, query)
1642    node: TreeSitterNode | None = utils.find_enclosing_scope(
1643        comments[0], CommentType.cs
1644    )
1645    assert node
1646    assert node.text
1647    func_def = node.text.decode("utf-8")
1648    assert result in func_def
1649
1650
1651@pytest.mark.parametrize(
1652    ("code", "num_comments", "result"),
1653    [
1654        (
1655            b"""
1656                // @req-id: need_001
1657                void dummy_func1(){
1658                }
1659            """,
1660            1,
1661            "// @req-id: need_001",
1662        ),
1663        (
1664            b"""
1665                void dummy_func1(){
1666                // @req-id: need_001
1667                }
1668            """,
1669            1,
1670            "// @req-id: need_001",
1671        ),
1672        (
1673            b"""
1674                /* @req-id: need_001 */
1675                void dummy_func1(){
1676                }
1677            """,
1678            1,
1679            "/* @req-id: need_001 */",
1680        ),
1681        (
1682            b"""
1683                //  @req-id: need_001
1684                //
1685                //
1686                void dummy_func1(){
1687                }
1688            """,
1689            3,
1690            "//  @req-id: need_001",
1691        ),
1692    ],
1693)
1694def test_cpp_comment(code, num_comments, result, init_cpp_tree_sitter):
1695    parser, query = init_cpp_tree_sitter
1696    comments = utils.extract_comments(code, parser, query)
1697    assert len(comments) == num_comments
1698    comments.sort(key=lambda x: x.start_point.row)
1699    assert comments[0].text
1700    assert comments[0].text.decode("utf-8") == result
1701
1702
1703@pytest.mark.parametrize(
1704    ("code", "num_comments", "result"),
1705    [
1706        (
1707            b"""
1708                # @req-id: need_001
1709                def dummy_func1():
1710                    pass
1711            """,
1712            1,
1713            "# @req-id: need_001",
1714        ),
1715        (
1716            b"""
1717                def dummy_func1():
1718                    # @req-id: need_001
1719                    pass
1720            """,
1721            1,
1722            "# @req-id: need_001",
1723        ),
1724        (
1725            b"""
1726                # single line comment
1727                # @req-id: need_001
1728                def dummy_func1():
1729                    pass
1730            """,
1731            2,
1732            "# single line comment",
1733        ),
1734        (
1735            b"""
1736                def dummy_func1():
1737                    '''
1738                    @req-id: need_001
1739                    '''
1740                    pass
1741            """,
1742            1,
1743            "'''\n                    @req-id: need_001\n                    '''",
1744        ),
1745        (
1746            b"""
1747                def dummy_func1():
1748                    text = '''@req-id: need_001, need_002, this docstring shall not be extracted as comment'''
1749                    # @req-id: need_001
1750                    pass
1751            """,
1752            1,
1753            "# @req-id: need_001",
1754        ),
1755    ],
1756)
1757def test_python_comment(code, num_comments, result, init_python_tree_sitter):
1758    parser, query = init_python_tree_sitter
1759    comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1760    comments.sort(key=lambda x: x.start_point.row)
1761    assert len(comments) == num_comments
1762    assert comments[0].text
1763    assert comments[0].text.decode("utf-8") == result
1764
1765
1766@pytest.mark.parametrize(
1767    ("code", "num_comments", "result"),
1768    [
1769        (
1770            b"""
1771                // @req-id: need_001
1772                void DummyFunc1(){
1773                }
1774            """,
1775            1,
1776            "// @req-id: need_001",
1777        ),
1778        (
1779            b"""
1780                void DummyFunc1(){
1781                // @req-id: need_001
1782                }
1783            """,
1784            1,
1785            "// @req-id: need_001",
1786        ),
1787        (
1788            b"""
1789                /* @req-id: need_001 */
1790                void DummyFunc1(){
1791                }
1792            """,
1793            1,
1794            "/* @req-id: need_001 */",
1795        ),
1796        (
1797            b"""
1798                //  @req-id: need_001
1799                //
1800                //
1801                void DummyFunc1(){
1802                }
1803            """,
1804            3,
1805            "//  @req-id: need_001",
1806        ),
1807    ],
1808)
1809def test_csharp_comment(code, num_comments, result, init_csharp_tree_sitter):
1810    parser, query = init_csharp_tree_sitter
1811    comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1812    comments.sort(key=lambda x: x.start_point.row)
1813    assert len(comments) == num_comments
1814    assert comments[0].text
1815    assert comments[0].text.decode("utf-8") == result
1816
1817
1818@pytest.mark.parametrize(
1819    ("code", "num_comments", "result"),
1820    [
1821        (
1822            b"""
1823                // @req-id: need_001
1824                function dummyFunc1() {
1825                }
1826            """,
1827            1,
1828            "// @req-id: need_001",
1829        ),
1830        (
1831            b"""
1832                /* @req-id: need_001 */
1833                const value = 1;
1834            """,
1835            1,
1836            "/* @req-id: need_001 */",
1837        ),
1838    ],
1839)
1840def test_typescript_comment(code, num_comments, result, init_typescript_tree_sitter):
1841    parser, query = init_typescript_tree_sitter
1842    comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1843    comments.sort(key=lambda x: x.start_point.row)
1844    assert len(comments) == num_comments
1845    assert comments[0].text
1846    assert comments[0].text.decode("utf-8") == result
1847
1848
1849@pytest.mark.parametrize(
1850    ("code", "num_comments", "result"),
1851    [
1852        (
1853            b"""
1854                # @req-id: need_001
1855                database:
1856                  host: localhost
1857            """,
1858            1,
1859            "# @req-id: need_001",
1860        ),
1861        (
1862            b"""
1863                services:
1864                  web:
1865                    # @req-id: need_001
1866                    image: nginx:latest
1867            """,
1868            1,
1869            "# @req-id: need_001",
1870        ),
1871        (
1872            b"""
1873                # Top level comment
1874                # @req-id: need_001
1875                version: "3.8"
1876            """,
1877            2,
1878            "# Top level comment",
1879        ),
1880    ],
1881)
1882def test_yaml_comment(code, num_comments, result, init_yaml_tree_sitter):
1883    parser, query = init_yaml_tree_sitter
1884    comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1885    comments.sort(key=lambda x: x.start_point.row)
1886    assert len(comments) == num_comments
1887    assert comments[0].text
1888    assert comments[0].text.decode("utf-8") == result
1889
1890
1891@pytest.mark.parametrize(
1892    ("code", "num_comments", "result"),
1893    [
1894        (
1895            b"""
1896                // @req-id: need_001
1897                func dummyFunc1() {
1898                }
1899            """,
1900            1,
1901            "// @req-id: need_001",
1902        ),
1903        (
1904            b"""
1905                func dummyFunc1() {
1906                // @req-id: need_001
1907                }
1908            """,
1909            1,
1910            "// @req-id: need_001",
1911        ),
1912        (
1913            b"""
1914                /* @req-id: need_001 */
1915                func dummyFunc1() {
1916                }
1917            """,
1918            1,
1919            "/* @req-id: need_001 */",
1920        ),
1921        (
1922            b"""
1923                //  @req-id: need_001
1924                //
1925                //
1926                func dummyFunc1() {
1927                }
1928            """,
1929            3,
1930            "//  @req-id: need_001",
1931        ),
1932    ],
1933)
1934def test_go_comment(code, num_comments, result, init_go_tree_sitter):
1935    parser, query = init_go_tree_sitter
1936    comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1937    comments.sort(key=lambda x: x.start_point.row)
1938    assert len(comments) == num_comments
1939    assert comments[0].text
1940    assert comments[0].text.decode("utf-8") == result
1941
1942
1943@pytest.mark.parametrize(
1944    ("code", "result"),
1945    [
1946        (
1947            b"""
1948                // @req-id: need_001
1949                func dummyFunc1() {
1950                }
1951            """,
1952            "func dummyFunc1()",
1953        ),
1954        (
1955            b"""
1956                func dummyFunc2() {
1957                }
1958                // @req-id: need_001
1959                func dummyFunc1() {
1960                }
1961            """,
1962            "func dummyFunc1()",
1963        ),
1964        (
1965            b"""
1966                /* @req-id: need_001 */
1967                func dummyFunc1() {
1968                }
1969            """,
1970            "func dummyFunc1()",
1971        ),
1972    ],
1973)
1974def test_find_associated_scope_go(code, result, init_go_tree_sitter):
1975    parser, query = init_go_tree_sitter
1976    comments = utils.extract_comments(code, parser, query)
1977    node: TreeSitterNode | None = utils.find_associated_scope(
1978        comments[0], CommentType.go
1979    )
1980    assert node
1981    assert node.text
1982    go_def = node.text.decode("utf-8")
1983    assert result in go_def
1984
1985
1986@pytest.mark.parametrize(
1987    ("code", "result"),
1988    [
1989        (
1990            b"""
1991                // @req-id: need_001
1992                func dummyFunc1() {
1993                }
1994            """,
1995            "func dummyFunc1()",
1996        ),
1997        (
1998            b"""
1999                // @req-id: need_001
2000                type DummyStruct struct {
2001                    Field int
2002                }
2003            """,
2004            "type DummyStruct struct",
2005        ),
2006    ],
2007)
2008def test_find_next_scope_go(code, result, init_go_tree_sitter):
2009    parser, query = init_go_tree_sitter
2010    comments = utils.extract_comments(code, parser, query)
2011    node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.go)
2012    assert node
2013    assert node.text
2014    go_def = node.text.decode("utf-8")
2015    assert result in go_def
2016
2017
2018@pytest.mark.parametrize(
2019    ("code", "result"),
2020    [
2021        (
2022            b"""
2023                func dummyFunc1() {
2024                    // @req-id: need_001
2025                }
2026            """,
2027            "func dummyFunc1()",
2028        ),
2029        (
2030            b"""
2031                func dummyFunc1() {
2032                    /* @req-id: need_001 */
2033                }
2034            """,
2035            "func dummyFunc1()",
2036        ),
2037    ],
2038)
2039def test_find_enclosing_scope_go(code, result, init_go_tree_sitter):
2040    parser, query = init_go_tree_sitter
2041    comments = utils.extract_comments(code, parser, query)
2042    node: TreeSitterNode | None = utils.find_enclosing_scope(
2043        comments[0], CommentType.go
2044    )
2045    assert node
2046    assert node.text
2047    go_def = node.text.decode("utf-8")
2048    assert result in go_def
2049
2050
2051@pytest.mark.parametrize(
2052    ("git_url", "rev", "project_path", "filepath", "lineno", "result"),
2053    [
2054        (
2055            "git@github.com:useblocks/sphinx-codelinks.git",
2056            "beef1234",
2057            Path(__file__).parent.parent,
2058            # ABSOLUTE, and under `project_path`. `form_https_url` resolves the
2059            # filepath with `.absolute()`, i.e. against the CWD, so a relative one
2060            # here only agrees with `project_path` when pytest happens to run from
2061            # the package directory -- which it does not when the whole workspace's
2062            # suite is invoked from the repository root
2063            Path(__file__).parent.parent / "example" / "to" / "here",
2064            3,
2065            "https://github.com/useblocks/sphinx-codelinks/blob/beef1234/example/to/here#L3",
2066        )
2067    ],
2068)
2069def test_form_https_url(
2070    git_url, rev, project_path, filepath, lineno, result
2071):  # need to have these args
2072    url = utils.form_https_url(git_url, rev, project_path, filepath, lineno=lineno)
2073    assert url == result
2074
2075
2076def get_git_path() -> str:
2077    """Get the path to the git executable."""
2078    git_path = shutil.which("git")
2079    if not git_path:
2080        raise FileNotFoundError("Git executable not found")
2081    if not Path(git_path).is_file():
2082        raise FileNotFoundError("Git executable path is invalid")
2083    return git_path
2084
2085
2086def init_git_repo(repo_path: Path, remote_url: str) -> Path:
2087    """Initialize a git repository for testing."""
2088    git_dir = repo_path / "test_repo"
2089    src_dir = git_dir / "src"
2090    src_dir.mkdir(parents=True)
2091
2092    git_path = get_git_path()
2093    if not git_path:
2094        raise FileNotFoundError("Git executable not found")
2095    if not Path(git_path).is_file():
2096        raise FileNotFoundError("Git executable path is invalid")
2097
2098    # Initialize git repo
2099    subprocess.run([git_path, "init"], cwd=git_dir, check=True, capture_output=True)
2100    subprocess.run(
2101        [git_path, "config", "user.email", "test@example.com"], cwd=git_dir, check=True
2102    )
2103    subprocess.run(
2104        [git_path, "config", "user.name", "Test User"], cwd=git_dir, check=True
2105    )
2106
2107    # Create a test file and commit
2108    test_file = src_dir / "test_file.py"
2109    test_file.write_text("# Test file\nprint('hello')\n")
2110    subprocess.run([git_path, "add", "."], cwd=git_dir, check=True)
2111    subprocess.run(
2112        [git_path, "commit", "-m", "Initial commit"], cwd=git_dir, check=True
2113    )
2114
2115    # Add a remote
2116    subprocess.run(
2117        [git_path, "remote", "add", "origin", remote_url],
2118        cwd=git_dir,
2119        check=True,
2120    )
2121
2122    return git_dir
2123
2124
2125@pytest.fixture(
2126    params=[
2127        ("test_repo_git", "git@github.com:test-user/test-repo.git"),
2128        ("test_repo_https", "https://github.com/test-user/test-repo.git"),
2129    ]
2130)
2131def git_repo(tmp_path: str, request: pytest.FixtureRequest) -> tuple[Path, str]:
2132    """Create git repos for testing."""
2133    repo_name, remote_url = request.param
2134    repo_path = Path(tmp_path) / repo_name
2135    repo_path = init_git_repo(repo_path, remote_url)
2136    return repo_path, remote_url
2137
2138
2139def get_current_commit_hash(git_dir: Path) -> str:
2140    """Get the current commit hash of the git repository."""
2141    git_path = get_git_path()
2142    result = subprocess.run(
2143        [git_path, "rev-parse", "HEAD"],
2144        cwd=git_dir,
2145        check=True,
2146        capture_output=True,
2147        text=True,
2148    )
2149
2150    return str(result.stdout.strip())
2151
2152
2153def test_locate_git_root(git_repo: tuple[Path, str]) -> None:
2154    repo_path = git_repo[0]
2155    src_dir = repo_path / "src"
2156    git_root = utils.locate_git_root(src_dir)
2157    assert git_root == repo_path
2158
2159
2160def test_get_remote_url(git_repo: tuple[Path, str]) -> None:
2161    repo_path, expected_url = git_repo
2162    remote_url = utils.get_remote_url(repo_path)
2163    assert remote_url == expected_url
2164
2165
2166def test_get_current_rev(git_repo: tuple[Path, str]) -> None:
2167    repo_path, _ = git_repo
2168    current_rev = get_current_commit_hash(repo_path)
2169    assert current_rev == utils.get_current_rev(repo_path)
2170
2171
2172@pytest.fixture(params=["detached", "branch"])
2173def git_worktree(
2174    tmp_path: Path, request: pytest.FixtureRequest
2175) -> tuple[Path, str, str]:
2176    """A LINKED WORKTREE of a scratch repository, in both shapes git makes.
2177
2178    A worktree is not a checkout with a `.git` directory: `.git` is a FILE holding
2179    `gitdir: <path>`, `config` lives in the main repository's git directory (named by
2180    the worktree's `commondir`), and only per-worktree state -- `HEAD` among it -- is in
2181    the worktree's own. `detached` puts a SHA in `HEAD`; `branch` puts `ref:
2182    refs/heads/...`, whose target is a shared ref in the common directory.
2183
2184    This repository is worked in worktrees as a matter of routine, so a helper that
2185    demands a `.git` directory fails for every developer while staying green in CI
2186    (useblocks/sphinx-codelinks#106).
2187    """
2188    remote_url = "https://github.com/test-user/test-repo.git"
2189    repo_path = init_git_repo(tmp_path / "main", remote_url)
2190    git_path = get_git_path()
2191    worktree_path = tmp_path / "worktree"
2192    add = [git_path, "worktree", "add"]
2193    add += (
2194        ["--detach", str(worktree_path), "HEAD"]
2195        if request.param == "detached"
2196        else [str(worktree_path), "-b", "a-worktree-branch"]
2197    )
2198    subprocess.run(add, cwd=repo_path, check=True, capture_output=True)
2199    assert (worktree_path / ".git").is_file(), "a linked worktree's .git is a file"
2200    return worktree_path, remote_url, get_current_commit_hash(repo_path)
2201
2202
2203def test_locate_git_root_worktree(git_worktree: tuple[Path, str, str]) -> None:
2204    worktree_path, _, _ = git_worktree
2205    assert utils.locate_git_root(worktree_path / "src") == worktree_path
2206
2207
2208def test_get_remote_url_worktree(git_worktree: tuple[Path, str, str]) -> None:
2209    worktree_path, remote_url, _ = git_worktree
2210    assert utils.get_remote_url(worktree_path) == remote_url
2211
2212
2213def test_get_current_rev_worktree(git_worktree: tuple[Path, str, str]) -> None:
2214    worktree_path, _, rev = git_worktree
2215    assert utils.get_current_rev(worktree_path) == rev
2216
2217
2218def test_get_current_rev_packed_refs(git_worktree: tuple[Path, str, str]) -> None:
2219    """`git gc` packs the loose refs away, and the branch ref is then in `packed-refs`.
2220
2221    Nothing unusual has to happen for this: `git gc` runs unattended on any long-lived
2222    checkout, and afterwards there is no `refs/heads/<branch>` FILE in either the
2223    worktree's git directory or the common one. It is the same failure shape as
2224    useblocks/sphinx-codelinks#106 reached by a different route -- and CI never sees it,
2225    because `actions/checkout` and Read the Docs both leave a detached HEAD.
2226    """
2227    worktree_path, _, rev = git_worktree
2228    git_path = get_git_path()
2229    subprocess.run(
2230        [git_path, "pack-refs", "--all"],
2231        cwd=worktree_path,
2232        check=True,
2233        capture_output=True,
2234    )
2235    git_dir = utils._git_dir(worktree_path)
2236    assert git_dir is not None
2237    common = utils._git_common_dir(git_dir)
2238    assert not list((common / "refs" / "heads").glob("*")), (
2239        "the loose refs should be gone"
2240    )
2241
2242    assert utils.get_current_rev(worktree_path) == rev
2243
2244
2245def test_get_current_rev_detached_head(tmp_path: Path) -> None:
2246    """In a detached HEAD (e.g. CI checkouts) .git/HEAD holds the commit SHA
2247    directly; get_current_rev returns it rather than warning and giving up."""
2248    git_root = tmp_path / "repo"
2249    (git_root / ".git").mkdir(parents=True)
2250    sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"
2251    (git_root / ".git" / "HEAD").write_text(f"{sha}\n")
2252
2253    assert utils.get_current_rev(git_root) == sha
2254
2255
2256# ========== YAML-specific tests ==========
2257
2258
2259@pytest.mark.parametrize(
2260    ("code", "expected_structure"),
2261    [
2262        # Basic key-value pair
2263        (
2264            b"""
2265                # Comment before key
2266                database:
2267                  host: localhost
2268            """,
2269            "database:",
2270        ),
2271        # Comment in nested structure
2272        (
2273            b"""
2274                services:
2275                  web:
2276                    # Comment before image
2277                    image: nginx:latest
2278            """,
2279            "image: nginx:latest",
2280        ),
2281        # Comment before list item
2282        (
2283            b"""
2284                items:
2285                  # Comment before list item
2286                  - name: item1
2287                    value: test
2288            """,
2289            "- name: item1",
2290        ),
2291        # Comment in document structure
2292        (
2293            b"""---
2294# Comment in document
2295version: "3.8"
2296services:
2297  app:
2298    build: .
2299            """,
2300            "version:",
2301        ),
2302        # Flow mapping structure
2303        (
2304            b"""
2305                # Comment before flow mapping
2306                config: {debug: true, port: 8080}
2307            """,
2308            "config:",
2309        ),
2310    ],
2311)
2312def test_find_yaml_next_structure(code, expected_structure, init_yaml_tree_sitter):
2313    """Test the find_yaml_next_structure function."""
2314    parser, query = init_yaml_tree_sitter
2315    comments = utils.extract_comments(code, parser, query)
2316    assert comments, "No comments found in the code"
2317
2318    next_structure = utils.find_yaml_next_structure(comments[0])
2319    assert next_structure, "No next structure found"
2320    structure_text = next_structure.text.decode("utf-8")
2321    assert expected_structure in structure_text
2322
2323
2324@pytest.mark.parametrize(
2325    ("code", "expected_structure"),
2326    [
2327        # Comment associated with key-value pair
2328        (
2329            b"""
2330                # Database configuration
2331                database:
2332                  host: localhost
2333                  port: 5432
2334            """,
2335            "database:",
2336        ),
2337        # Comment associated with nested structure
2338        (
2339            b"""
2340                services:
2341                  web:
2342                    # Web service image
2343                    image: nginx:latest
2344                    ports:
2345                      - "80:80"
2346            """,
2347            "image: nginx:latest",
2348        ),
2349        # Comment associated with list item
2350        (
2351            b"""
2352                dependencies:
2353                  # First dependency
2354                  - name: redis
2355                    version: "6.0"
2356                  - name: postgres
2357                    version: "13"
2358            """,
2359            "- name: redis",
2360        ),
2361        # Comment inside parent structure
2362        (
2363            b"""
2364                app:
2365                  # Internal comment
2366                  name: myapp
2367                  version: "1.0"
2368            """,
2369            "name: myapp",
2370        ),
2371    ],
2372)
2373def test_find_yaml_associated_structure(
2374    code, expected_structure, init_yaml_tree_sitter
2375):
2376    """Test the find_yaml_associated_structure function."""
2377    parser, query = init_yaml_tree_sitter
2378    comments = utils.extract_comments(code, parser, query)
2379    assert comments, "No comments found in the code"
2380
2381    associated_structure = utils.find_yaml_associated_structure(comments[0])
2382    assert associated_structure, "No associated structure found"
2383    structure_text = associated_structure.text.decode("utf-8")
2384    assert expected_structure in structure_text
2385
2386
2387@pytest.mark.parametrize(
2388    ("code", "expected_results"),
2389    [
2390        # Multiple comments in sequence
2391        (
2392            b"""
2393                # First comment
2394                # Second comment
2395                database:
2396                  host: localhost
2397            """,
2398            ["database:", "database:"],  # Both comments should associate with database
2399        ),
2400        # Comments at different nesting levels
2401        (
2402            b"""
2403                # Top level comment
2404                services:
2405                  web:
2406                    # Nested comment
2407                    image: nginx:latest
2408            """,
2409            ["services:", "image: nginx:latest"],
2410        ),
2411    ],
2412)
2413def test_multiple_yaml_comments(code, expected_results, init_yaml_tree_sitter):
2414    """Test handling of multiple YAML comments in the same file."""
2415    parser, query = init_yaml_tree_sitter
2416    comments = utils.extract_comments(code, parser, query)
2417    comments.sort(key=lambda x: x.start_point.row)
2418
2419    assert len(comments) == len(expected_results), (
2420        f"Expected {len(expected_results)} comments, found {len(comments)}"
2421    )
2422
2423    for i, comment in enumerate(comments):
2424        associated_structure = utils.find_yaml_associated_structure(comment)
2425        assert associated_structure, f"No associated structure found for comment {i}"
2426        structure_text = associated_structure.text.decode("utf-8")
2427        assert expected_results[i] in structure_text
2428
2429
2430@pytest.mark.parametrize(
2431    ("code", "has_structure"),
2432    [
2433        # Comment at end of file with no following structure
2434        (
2435            b"""
2436database:
2437  host: localhost
2438# End of file comment
2439            """,
2440            True,  # This will actually find the parent database structure
2441        ),
2442        # Comment with only whitespace after
2443        (
2444            b"""
2445                # Lonely comment
2446
2447
2448            """,
2449            False,
2450        ),
2451        # Comment before valid structure
2452        (
2453            b"""
2454                # Valid comment
2455                key: value
2456            """,
2457            True,
2458        ),
2459    ],
2460)
2461def test_yaml_edge_cases(code, has_structure, init_yaml_tree_sitter):
2462    """Test edge cases in YAML comment processing."""
2463    parser, query = init_yaml_tree_sitter
2464    comments = utils.extract_comments(code, parser, query)
2465
2466    if comments:
2467        structure = utils.find_yaml_associated_structure(comments[0])
2468        if has_structure:
2469            assert structure, "Expected to find associated structure"
2470        else:
2471            assert structure is None, "Expected no associated structure"
2472    else:
2473        assert not has_structure, "No comments found but structure was expected"
2474
2475
2476@pytest.mark.parametrize(
2477    ("code", "expected_structures"),
2478    [
2479        # Simpler nested YAML structure
2480        (
2481            b"""# Global configuration
2482version: "3.8"
2483
2484# Services section
2485services:
2486  web:
2487    image: nginx:latest
2488    # Port configuration
2489    ports:
2490      - "80:80"
2491            """,
2492            [
2493                "version:",  # Global configuration
2494                "services:",  # Services section
2495                '- "80:80"',  # Port configuration
2496            ],
2497        ),
2498    ],
2499)
2500def test_complex_yaml_structure(code, expected_structures, init_yaml_tree_sitter):
2501    """Test complex nested YAML structures with multiple comments."""
2502    parser, query = init_yaml_tree_sitter
2503    comments = utils.extract_comments(code, parser, query)
2504    comments.sort(key=lambda x: x.start_point.row)
2505
2506    assert len(comments) == len(expected_structures), (
2507        f"Expected {len(expected_structures)} comments, found {len(comments)}"
2508    )
2509
2510    for i, comment in enumerate(comments):
2511        associated_structure = utils.find_yaml_associated_structure(comment)
2512        assert associated_structure, f"No associated structure found for comment {i}"
2513        structure_text = associated_structure.text.decode("utf-8")
2514        assert expected_structures[i] in structure_text, (
2515            f"Expected '{expected_structures[i]}' in structure text: '{structure_text}'"
2516        )
2517
2518
2519@pytest.mark.parametrize(
2520    ("code", "expected_type"),
2521    [
2522        # Block mapping pair
2523        (
2524            b"""
2525                # Comment
2526                key: value
2527            """,
2528            "block_mapping_pair",
2529        ),
2530        # Block sequence item
2531        (
2532            b"""
2533                items:
2534                  # Comment
2535                  - item1
2536            """,
2537            "block_sequence_item",
2538        ),
2539        # Nested block mapping
2540        (
2541            b"""
2542                services:
2543                  # Comment
2544                  web:
2545                    image: nginx
2546            """,
2547            "block_mapping_pair",
2548        ),
2549    ],
2550)
2551def test_yaml_structure_types(code, expected_type, init_yaml_tree_sitter):
2552    """Test that YAML structures return the correct node types."""
2553    parser, query = init_yaml_tree_sitter
2554    comments = utils.extract_comments(code, parser, query)
2555    assert comments, "No comments found"
2556
2557    structure = utils.find_yaml_associated_structure(comments[0])
2558    assert structure, "No associated structure found"
2559    assert structure.type == expected_type, (
2560        f"Expected type {expected_type}, got {structure.type}"
2561    )
2562
2563
2564def test_yaml_document_structure(init_yaml_tree_sitter):
2565    """Test YAML document structure handling."""
2566    code = b"""---
2567# Document comment
2568apiVersion: v1
2569kind: ConfigMap
2570metadata:
2571  name: my-config
2572data:
2573  # Data comment
2574  config.yml: |
2575    setting: value
2576    """
2577
2578    parser, query = init_yaml_tree_sitter
2579    comments = utils.extract_comments(code, parser, query)
2580    comments.sort(key=lambda x: x.start_point.row)
2581
2582    # Should find both comments
2583    assert len(comments) >= 2, f"Expected at least 2 comments, found {len(comments)}"
2584
2585    # First comment should associate with apiVersion
2586    first_structure = utils.find_yaml_associated_structure(comments[0])
2587    assert first_structure, "No structure found for first comment"
2588    first_text = first_structure.text.decode("utf-8")
2589    assert "apiVersion:" in first_text
2590
2591    # Second comment should associate with config.yml
2592    second_structure = utils.find_yaml_associated_structure(comments[1])
2593    assert second_structure, "No structure found for second comment"
2594    second_text = second_structure.text.decode("utf-8")
2595    assert "config.yml:" in second_text
2596
2597
2598def test_yaml_inline_comments_current_behavior(init_yaml_tree_sitter):
2599    """Test improved behavior of inline comments in YAML after the fix."""
2600    code = b"""key1: value1  # inline comment about key1
2601key2: value2
2602key3: value3  # inline comment about key3
2603"""
2604
2605    parser, query = init_yaml_tree_sitter
2606    comments = utils.extract_comments(code, parser, query)
2607    comments.sort(key=lambda x: x.start_point.row)
2608
2609    assert len(comments) == 2, f"Expected 2 comments, found {len(comments)}"
2610
2611    # Fixed behavior: inline comment about key1 now correctly associates with key1
2612    first_structure = utils.find_yaml_associated_structure(comments[0])
2613    assert first_structure, "No structure found for first comment"
2614    first_text = first_structure.text.decode("utf-8")
2615    assert "key1:" in first_text, f"Expected 'key1:' in '{first_text}'"
2616
2617    # Fixed behavior: inline comment about key3 now correctly associates with key3
2618    second_structure = utils.find_yaml_associated_structure(comments[1])
2619    assert second_structure, "No structure found for second comment"
2620    second_text = second_structure.text.decode("utf-8")
2621    assert "key3:" in second_text, f"Expected 'key3:' in '{second_text}'"
2622
2623
2624@pytest.mark.parametrize(
2625    ("code", "expected_associations"),
2626    [
2627        # Basic inline comment case
2628        (
2629            b"""key1: value1  # comment about key1
2630key2: value2
2631            """,
2632            ["key1:"],  # Now correctly associates with key1
2633        ),
2634        # Multiple inline comments
2635        (
2636            b"""database:
2637  host: localhost  # production server
2638  port: 5432       # default postgres port
2639  user: admin
2640            """,
2641            [
2642                "host: localhost",
2643                "port: 5432",
2644            ],  # Now correctly associates with the right structures
2645        ),
2646    ],
2647)
2648def test_yaml_inline_comments_fixed_behavior(
2649    code, expected_associations, init_yaml_tree_sitter
2650):
2651    """Test that inline comments now correctly associate with the structure they comment on."""
2652    parser, query = init_yaml_tree_sitter
2653    comments = utils.extract_comments(code, parser, query)
2654    comments.sort(key=lambda x: x.start_point.row)
2655
2656    assert len(comments) == len(expected_associations), (
2657        f"Expected {len(expected_associations)} comments, found {len(comments)}"
2658    )
2659
2660    for i, comment in enumerate(comments):
2661        structure = utils.find_yaml_associated_structure(comment)
2662        assert structure, f"No structure found for comment {i}"
2663        structure_text = structure.text.decode("utf-8")
2664        assert expected_associations[i] in structure_text, (
2665            f"Expected '{expected_associations[i]}' in structure text: '{structure_text}'"
2666        )
2667
2668
2669@pytest.mark.parametrize(
2670    ("code", "expected_associations"),
2671    [
2672        # Inline comments with list items
2673        (
2674            b"""items:
2675  - name: item1  # first item
2676  - name: item2  # second item
2677            """,
2678            [
2679                "name: item1",
2680                "name: item2",
2681            ],  # The inline comment finds the key-value pair within the list item
2682        ),
2683        # Mixed inline and block comments
2684        (
2685            b"""# Block comment for database
2686database:
2687  host: localhost  # inline comment for host
2688  port: 5432
2689  # Block comment for user
2690  user: admin
2691            """,
2692            ["database:", "host: localhost", "user: admin"],
2693        ),
2694        # Inline comments in nested structures
2695        (
2696            b"""services:
2697  web:
2698    image: nginx  # web server image
2699    ports:
2700      - "80:80"   # http port
2701            """,
2702            ["image: nginx", '- "80:80"'],
2703        ),
2704    ],
2705)
2706def test_yaml_inline_comments_comprehensive(
2707    code, expected_associations, init_yaml_tree_sitter
2708):
2709    """Comprehensive test for inline comment behavior in various YAML structures."""
2710    parser, query = init_yaml_tree_sitter
2711    comments = utils.extract_comments(code, parser, query)
2712    comments.sort(key=lambda x: x.start_point.row)
2713
2714    assert len(comments) == len(expected_associations), (
2715        f"Expected {len(expected_associations)} comments, found {len(comments)}"
2716    )
2717
2718    for i, comment in enumerate(comments):
2719        structure = utils.find_yaml_associated_structure(comment)
2720        assert structure, (
2721            f"No structure found for comment {i}: '{comment.text.decode('utf-8')}'"
2722        )
2723        structure_text = structure.text.decode("utf-8")
2724        assert expected_associations[i] in structure_text, (
2725            f"Comment {i} '{comment.text.decode('utf-8')}' -> Expected '{expected_associations[i]}' in '{structure_text}'"
2726        )
2727
2728
2729@pytest.mark.parametrize(
2730    ("text", "expected"),
2731    [
2732        pytest.param("a@file.com", False, id="address"),
2733        pytest.param("x@module", False, id="no-left-boundary"),
2734        pytest.param("* @file", True, id="after-star"),
2735        pytest.param("\\file widget.h", True, id="backslash-at-start"),
2736        pytest.param("@filename", False, id="longer-word"),
2737        pytest.param("@file_x", False, id="underscore"),
2738        pytest.param("/// @fileoverview Widgets.", True, id="fileoverview"),
2739        pytest.param("@fileͅ x", True, id="non-ascii-mark-ends-the-word"),
2740    ],
2741)
2742def test_documents_file(text: str, expected: bool) -> None:
2743    """A file tag counts at the start of the text or after whitespace, ``*``, ``/`` or
2744    ``!``, and only as a whole ASCII word: an address or a longer word never counts,
2745    and a non-ASCII mark ends the word rather than extending it."""
2746    assert utils.documents_file(text) is expected
2747
2748
2749@pytest.mark.parametrize(
2750    "line",
2751    [
2752        ".COMPONENT",
2753        ".ROLE",
2754        ".FUNCTIONALITY",
2755        ".FORWARDHELPTARGETNAME Get-Item",
2756        ".FORWARDHELPCATEGORY Cmdlet",
2757        ".REMOTEHELPRUNSPACE $session",
2758        ".EXTERNALHELP Px-help.xml",
2759        ".forwardhelptargetname",
2760    ],
2761)
2762def test_every_powershell_help_keyword_makes_help(line, init_powershell_tree_sitter):
2763    """Each of PowerShell's comment-based-help keywords, the rarer ones included, makes
2764    a comment first in a function's body that function's help."""
2765    parser, query = init_powershell_tree_sitter
2766    source = f"function Outer {{\n    <#\n    {line}\n    marker\n    #>\n    function Inner {{ }}\n}}\n"
2767    assert _powershell_attached(source, parser, query) == (
2768        "function Outer {",
2769        "doc_comment",
2770    )
2771
2772
2773@pytest.mark.parametrize(
2774    ("source", "expected"),
2775    [
2776        pytest.param(
2777            "Describe 'outer' {\n    # marker\n    Describe 'x' { } | Out-Null\n}\n",
2778            ("Describe 'x' { }", "leading"),
2779            id="leading-entity-first",
2780        ),
2781        pytest.param(
2782            "Describe 'outer' {\n    It 'tx' { } | Out-Null # marker\n}\n",
2783            ("It 'tx' { }", "trailing"),
2784            id="trailing-entity-first",
2785        ),
2786        pytest.param(
2787            "Describe 'outer' {\n    # marker\n    Get-Thing | It 'y' { }\n}\n",
2788            ("It 'y' { }", "leading"),
2789            id="leading-entity-after-a-command",
2790        ),
2791        pytest.param(
2792            "Describe 'outer' {\n    # marker\n    It 'a' { } && It 'b' { }\n}\n",
2793            ("It 'a' { }", "leading"),
2794            id="leading-chain-of-two-entities",
2795        ),
2796        # the comment is in ForEach-Object's block, not in the Describe before it
2797        pytest.param(
2798            "Describe 'outer' {\n    Describe 'x' { } | ForEach-Object {\n"
2799            "        # marker\n        $_\n    }\n}\n",
2800            ("Describe 'outer' {", "enclosing"),
2801            id="enclosing-past-a-pipeline-entity-it-is-not-in",
2802        ),
2803        pytest.param(
2804            "Describe 'outer' {\n    # marker\n    Get-Thing | Out-Null\n}\n",
2805            ("Describe 'outer' {", "enclosing"),
2806            id="pipeline-of-no-entity",
2807        ),
2808    ],
2809)
2810def test_a_powershell_pipeline_is_its_first_entity(
2811    source, expected, init_powershell_tree_sitter
2812):
2813    """A pipeline (or a chain of them) is the first of its commands, in source order,
2814    that is an entity, on the leading and the trailing path alike; without one it is no
2815    entity. A comment inside a command of a pipeline is in that command, never in the
2816    pipeline's entity when it is another command."""
2817    parser, query = init_powershell_tree_sitter
2818    assert _powershell_attached(source, parser, query) == expected