[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
  61def test_every_comment_type_is_wired():
  62    """A comment type is a ``CommentType`` member plus an entry in each of several
  63    tables; a member one of them misses fails here, rather than as a ``KeyError`` in
  64    discovery or as no scope at all."""
  65    members = set(CommentType)
  66    assert set(COMMENT_FILETYPE) == {member.value for member in members}
  67    # YAML and JSONC bind to data structures, not scopes (find_associated_scope)
  68    assert set(utils.SCOPE_NODE_TYPES) == members - {
  69        CommentType.yaml,
  70        CommentType.jsonc,
  71    }
  72    assert set(utils.WRAPPER_TYPES) <= set(utils.ENTITY_NODE_TYPES)
  73    assert set(utils.TRIVIA_TYPES) <= set(utils.ENTITY_NODE_TYPES)
  74    for comment_type, predicates in utils.SCOPE_PREDICATES.items():
  75        assert set(predicates) <= utils.SCOPE_NODE_TYPES[comment_type]
  76    for member in members:
  77        utils.init_tree_sitter(member)
  78
  79
  80@pytest.mark.parametrize(
  81    ("code", "result"),
  82    [
  83        (
  84            b"""
  85                // @req-id: need_001
  86                void dummy_func1(){
  87                }
  88            """,
  89            "void dummy_func1()",
  90        ),
  91        (
  92            b"""
  93                void dummy_func2(){
  94                }
  95                // @req-id: need_001
  96                void dummy_func1(){
  97                }
  98            """,
  99            "void dummy_func1()",
 100        ),
 101        (
 102            b"""
 103                void dummy_func1(){
 104                    a = 1;
 105                    /* @req-id: need_001 */
 106                }
 107            """,
 108            "void dummy_func1()",
 109        ),
 110        (
 111            b"""
 112                void dummy_func1(){
 113                    // @req-id: need_001
 114                    a = 1;
 115                }
 116                void dummy_func2(){
 117                }
 118            """,
 119            "void dummy_func1()",
 120        ),
 121    ],
 122)
 123def test_find_associated_scope_cpp(code, result, init_cpp_tree_sitter):
 124    parser, query = init_cpp_tree_sitter
 125    comments = utils.extract_comments(code, parser, query)
 126    node: TreeSitterNode | None = utils.find_associated_scope(
 127        comments[0], CommentType.cpp
 128    )
 129    assert node
 130    assert node.text
 131    func_def = node.text.decode("utf-8")
 132    assert result in func_def
 133
 134
 135#: What :func:`_attached_to` gives for a marker rule 7 attaches to nothing
 136ABOVE_CONDITIONAL = utils.Unattached.above_conditional
 137
 138
 139def _attached_to(
 140    source: str, parser: Parser, query: Query
 141) -> tuple[str, str, str] | utils.Unattached | None:
 142    """What the comment holding ``marker`` in the C++ ``source`` is attached to: the
 143    entity's node kind, its first line and the mechanism; :data:`ABOVE_CONDITIONAL`
 144    for nothing by rule 7, ``None`` for nothing by any other rule."""
 145    comments = utils.extract_comments(source.encode(), parser, query) or []
 146    marker = next(c for c in comments if b"marker" in (c.text or b""))
 147    association = utils.associate(marker, CommentType.cpp)
 148    if association is None or isinstance(association, utils.Unattached):
 149        return association
 150    first_line = (association.node.text or b"").decode().splitlines()[0]
 151    return association.node.type, first_line, association.mechanism
 152
 153
 154#: The conditionals a marker can stand directly above, each the body of a namespace or a
 155#: class with the marker as its first line
 156CONDITIONAL_ARMS = {
 157    "one-arm": "#if FEATURE_B\nvoid active() {}\n#endif\n",
 158    "ifdef": "#ifdef FEATURE_B\nvoid active() {}\n#endif\n",
 159    "ifndef": "#ifndef FEATURE_B\nvoid active() {}\n#endif\n",
 160    "else-different-functions": (
 161        "#if FEATURE_B\nvoid active() {}\n#else\nvoid fallback() {}\n#endif\n"
 162    ),
 163    "else-same-function": (
 164        "#if FEATURE_B\nvoid active() { fast(); }\n"
 165        "#else\nvoid active() { slow(); }\n#endif\n"
 166    ),
 167    "elif": (
 168        "#if FEATURE_A\nvoid a() {}\n#elif FEATURE_B\nvoid b() {}\n"
 169        "#else\nvoid c() {}\n#endif\n"
 170    ),
 171    "declaration-only": "#if FEATURE_B\nvoid declared();\n#endif\n",
 172}
 173
 174#: The marker as the last line of an arm, directly above the next directive of the
 175#: same conditional
 176ARM_ENDINGS = {
 177    "above-else": (
 178        "#if FEATURE_B\nvoid active() {}\n// marker\n#else\nvoid fallback() {}\n#endif\n"
 179    ),
 180    "above-elif": (
 181        "#if FEATURE_A\nvoid a() {}\n// marker\n#elif FEATURE_B\nvoid b() {}\n#endif\n"
 182    ),
 183    "above-elifdef": (
 184        "#ifdef FEATURE_A\nvoid a() {}\n// marker\n#elifdef FEATURE_B\nvoid b() {}\n"
 185        "#endif\n"
 186    ),
 187    "above-endif": "#if FEATURE_B\nvoid active() {}\n// marker\n#endif\n",
 188    "above-endif-after-else": (
 189        "#if FEATURE_B\nvoid a() {}\n#else\nvoid b() {}\n// marker\n#endif\n"
 190    ),
 191}
 192
 193CONTAINERS = {
 194    "namespace": ("namespace lib {\n", "}\n"),
 195    "class": ("class Widget {\n", "};\n"),
 196}
 197
 198
 199@pytest.mark.parametrize("container", list(CONTAINERS))
 200@pytest.mark.parametrize(
 201    "body",
 202    [
 203        *(
 204            pytest.param(f"// marker\n{arms}", id=name)
 205            for name, arms in CONDITIONAL_ARMS.items()
 206        ),
 207        *(pytest.param(body, id=name) for name, body in ARM_ENDINGS.items()),
 208        pytest.param("/// marker\n" + CONDITIONAL_ARMS["one-arm"], id="doc-comment"),
 209        pytest.param(
 210            "// marker\n// and its second line\n" + CONDITIONAL_ARMS["one-arm"],
 211            id="comment-run",
 212        ),
 213        pytest.param(
 214            "/* marker\n */\n" + CONDITIONAL_ARMS["one-arm"], id="block-comment"
 215        ),
 216    ],
 217)
 218def test_marker_directly_above_a_conditional_is_on_nothing(
 219    container: str, body: str, init_cpp_tree_sitter: tuple[Parser, Query]
 220) -> None:
 221    """A marker directly above a preprocessor conditional's line, in a namespace or a
 222    class body, is attached to nothing, as clang attaches it: it would otherwise trace
 223    every unit of the container, whichever arm is compiled. ``#elif``, ``#else`` and
 224    ``#endif`` count as the opening directives do, and other comments may stand between
 225    when no blank line does."""
 226    head, tail = CONTAINERS[container]
 227
 228    assert _attached_to(head + body + tail, *init_cpp_tree_sitter) is ABOVE_CONDITIONAL
 229
 230
 231@pytest.mark.parametrize(
 232    "source",
 233    [
 234        pytest.param(
 235            "struct S {\n// marker\n" + CONDITIONAL_ARMS["one-arm"] + "};\n",
 236            id="struct",
 237        ),
 238        pytest.param(
 239            "union U {\n// marker\n#if FEATURE_B\nint a;\n#endif\nfloat b;\n};\n",
 240            id="union",
 241        ),
 242        pytest.param(
 243            "enum E {\n// marker\n#if FEATURE_B\nA,\n#endif\nB,\n};\n", id="enum"
 244        ),
 245        pytest.param(
 246            'namespace lib {\nextern "C" {\n// marker\n'
 247            + CONDITIONAL_ARMS["declaration-only"]
 248            + "}\n}\n",
 249            id="extern-c-block-in-a-namespace",
 250        ),
 251    ],
 252)
 253def test_marker_directly_above_a_conditional_is_on_nothing_in_any_body(
 254    source: str, init_cpp_tree_sitter: tuple[Parser, Query]
 255) -> None:
 256    """Not only in a namespace or class body: anywhere outside a function's body, a
 257    struct, union or enum body and an ``extern "C"`` block included, a marker directly
 258    above a conditional is attached to nothing, where the struct, union or enum, or
 259    the namespace around the block, held it."""
 260    assert _attached_to(source, *init_cpp_tree_sitter) is ABOVE_CONDITIONAL
 261
 262
 263@pytest.mark.parametrize(
 264    ("source", "expected"),
 265    [
 266        pytest.param(
 267            "// marker\n" + CONDITIONAL_ARMS["one-arm"],
 268            ABOVE_CONDITIONAL,
 269            id="file-level-first",
 270        ),
 271        pytest.param(
 272            "void first() {}\n// marker\n" + CONDITIONAL_ARMS["one-arm"],
 273            ABOVE_CONDITIONAL,
 274            id="file-level-after-an-item",
 275        ),
 276        pytest.param(
 277            "// marker\n\n" + CONDITIONAL_ARMS["one-arm"], None, id="file-header"
 278        ),
 279        pytest.param("void first() {}\n// marker\n", None, id="file-level-at-the-end"),
 280        pytest.param(
 281            "void outer() {\n// marker\n#if FEATURE_B\nfast();\n#endif\n}\n",
 282            ("function_definition", "void outer() {", "enclosing"),
 283            id="function-body",
 284        ),
 285        pytest.param(
 286            "namespace lib {\nvoid outer() {\n// marker\n#ifdef FEATURE_B\nfast();\n"
 287            "#endif\n}\n}\n",
 288            ("function_definition", "void outer() {", "enclosing"),
 289            id="function-body-in-a-namespace",
 290        ),
 291        pytest.param(
 292            "namespace lib {\nvoid first() {} // marker\n"
 293            + CONDITIONAL_ARMS["one-arm"]
 294            + "}\n",
 295            ("function_definition", "void first() {}", "trailing"),
 296            id="trailing",
 297        ),
 298        pytest.param(
 299            "namespace lib {\n// marker\n\n" + CONDITIONAL_ARMS["one-arm"] + "}\n",
 300            ("namespace_definition", "namespace lib {", "enclosing"),
 301            id="blank-line-namespace",
 302        ),
 303        pytest.param(
 304            "class Widget {\n// marker\n\n" + CONDITIONAL_ARMS["one-arm"] + "};\n",
 305            ("class_specifier", "class Widget {", "enclosing"),
 306            id="blank-line-class",
 307        ),
 308        pytest.param(
 309            "namespace lib {\n// marker\n// and its second line\n\n"
 310            + CONDITIONAL_ARMS["one-arm"]
 311            + "}\n",
 312            ("namespace_definition", "namespace lib {", "enclosing"),
 313            id="blank-line-after-a-comment-run",
 314        ),
 315        pytest.param(
 316            "namespace lib {\n#if FEATURE_B\nvoid active() {}\n// marker\n\n#endif\n}\n",
 317            ("namespace_definition", "namespace lib {", "enclosing"),
 318            id="blank-line-above-endif",
 319        ),
 320        pytest.param(
 321            "namespace lib {\n// marker\n#define FAST 1\nvoid active() {}\n}\n",
 322            ("namespace_definition", "namespace lib {", "enclosing"),
 323            id="above-define",
 324        ),
 325        pytest.param(
 326            'namespace lib {\n// marker\n#include "fast.h"\nvoid active() {}\n}\n',
 327            ("namespace_definition", "namespace lib {", "enclosing"),
 328            id="above-include",
 329        ),
 330        pytest.param(
 331            "class Widget {\n// marker\n#pragma pack(1)\nvoid active() {}\n};\n",
 332            ("class_specifier", "class Widget {", "enclosing"),
 333            id="above-pragma",
 334        ),
 335        pytest.param(
 336            "namespace lib {\n#if FEATURE_B\n// marker\nvoid active() {}\n#endif\n}\n",
 337            ("function_definition", "void active() {}", "leading"),
 338            id="inside-the-arm",
 339        ),
 340        pytest.param(
 341            "void outer() {\nstruct L {\n// marker\n"
 342            + CONDITIONAL_ARMS["one-arm"]
 343            + "};\n}\n",
 344            ("struct_specifier", "struct L {", "enclosing"),
 345            id="class-local-to-a-function",
 346        ),
 347        pytest.param(
 348            "namespace lib {\nauto l = []() {\n// marker\n#if FEATURE_B\nactive();\n"
 349            "#endif\n};\nvoid after() {}\n}\n",
 350            ("namespace_definition", "namespace lib {", "enclosing"),
 351            id="lambda-in-a-namespace",
 352        ),
 353        pytest.param(
 354            "namespace lib {\n// marker\n%:if FEATURE_B\nvoid active() {}\n%:endif\n}\n",
 355            ("namespace_definition", "namespace lib {", "enclosing"),
 356            id="digraph-if",
 357        ),
 358        pytest.param(
 359            "namespace lib {\n#if FEATURE_B\nvoid active() {}\n// marker\n%:endif\n}\n",
 360            ("namespace_definition", "namespace lib {", "enclosing"),
 361            id="digraph-endif",
 362        ),
 363        pytest.param(
 364            "namespace lib {\n#define F(x) \\\n  /* marker */ \\\n  # if x\n"
 365            "void active() {}\n}\n",
 366            ("namespace_definition", "namespace lib {", "enclosing"),
 367            id="define-continuation",
 368        ),
 369    ],
 370)
 371def test_marker_near_a_conditional_keeps_its_attachment(
 372    source: str,
 373    expected: tuple[str, str, str] | utils.Unattached | None,
 374    init_cpp_tree_sitter: tuple[Parser, Query],
 375) -> None:
 376    """Around a preprocessor conditional, every other marker keeps the attachment it
 377    has: at file level it is on nothing anyway, by rule 7 when directly above the
 378    conditional and by another rule (a file header, nothing after it) otherwise; in a function's body it is the
 379    function's; a trailing marker is its row's entity's; a blank line between makes it
 380    a comment on the namespace or class as a whole; any other directive (``#define``,
 381    ``#include``, ``#pragma``) breaks the attachment to the next entity as any code
 382    does; and inside the arm, the marker leads the definition, which is the remedy.
 383
 384    A lambda's body and a class defined in a function are a function's body too. And
 385    the directive is read as the grammar reads it: a digraph (``%:if``) is no
 386    conditional to it, nor is a ``# if`` line that continues a ``#define``."""
 387    assert _attached_to(source, *init_cpp_tree_sitter) == expected
 388
 389
 390@pytest.mark.parametrize(
 391    ("code", "result"),
 392    [
 393        (
 394            b"""
 395                def dummy_func1():
 396                    # @req-id: need_001
 397                    pass
 398            """,
 399            "def dummy_func1()",
 400        ),
 401        (
 402            b"""
 403                def dummy_func1():
 404                    # @req-id: need_002
 405                    def dummy_func2():
 406                        pass
 407                    pass
 408            """,
 409            "def dummy_func2()",
 410        ),
 411        (
 412            b"""
 413                def dummy_func1():
 414                '''@req-id: need_002'''
 415                    def nested_dummy_func():
 416                        pass
 417                    pass
 418            """,
 419            "def dummy_func1()",
 420        ),
 421        (
 422            b"""
 423                def dummy_func1():
 424                    def nested_dummy_func():
 425                        '''@req-id: need_002'''
 426                        pass
 427                    pass
 428            """,
 429            "def nested_dummy_func()",
 430        ),
 431        (
 432            b"""
 433                def dummy_func1():
 434                    def nested_dummy_func():
 435                        # @req-id: need_002
 436                        pass
 437                    pass
 438            """,
 439            "def nested_dummy_func()",
 440        ),
 441        (
 442            b"""
 443                def dummy_func1():
 444                    def nested_dummy_func():
 445                        pass
 446                    # @req-id: need_002
 447                    pass
 448            """,
 449            "def dummy_func1()",
 450        ),
 451    ],
 452)
 453def test_find_associated_scope_python(code, result, init_python_tree_sitter):
 454    parser, query = init_python_tree_sitter
 455    comments = utils.extract_comments(code, parser, query)
 456    node: TreeSitterNode | None = utils.find_associated_scope(
 457        comments[0], CommentType.python
 458    )
 459    assert node
 460    assert node.text
 461    func_def = node.text.decode("utf-8")
 462    assert func_def.startswith(result)
 463
 464
 465@pytest.mark.parametrize(
 466    ("code", "result"),
 467    [
 468        (
 469            b"""
 470                // @req-id: need_001
 471                public class DummyClass1
 472                {
 473                }
 474            """,
 475            "public class DummyClass1",
 476        ),
 477        (
 478            b"""
 479                public class DummyClass2
 480                {
 481                    // @req-id: need_001
 482                    public void DummyFunc2()
 483                    {
 484                    }
 485                }
 486            """,
 487            "public void DummyFunc2",
 488        ),
 489        (
 490            b"""
 491                public class DummyClass3
 492                {
 493                    // @req-id: need_001
 494                    public string Property1 { get; set; }
 495                }
 496            """,
 497            "public string Property1",
 498        ),
 499    ],
 500)
 501def test_find_associated_scope_csharp(code, result, init_csharp_tree_sitter):
 502    parser, query = init_csharp_tree_sitter
 503    comments = utils.extract_comments(code, parser, query)
 504    node: TreeSitterNode | None = utils.find_associated_scope(
 505        comments[0], CommentType.cs
 506    )
 507    assert node
 508    assert node.text
 509    func_def = node.text.decode("utf-8")
 510    assert func_def.startswith(result)
 511
 512
 513@pytest.mark.parametrize(
 514    ("code", "result"),
 515    [
 516        (
 517            b"""
 518                # @req-id: need_001
 519                database:
 520                  host: localhost
 521                  port: 5432
 522            """,
 523            "database:",
 524        ),
 525        (
 526            b"""
 527                services:
 528                  web:
 529                    # @req-id: need_002
 530                    image: nginx:latest
 531                    ports:
 532                      - "80:80"
 533            """,
 534            "image: nginx:latest",
 535        ),
 536        (
 537            b"""
 538                # @req-id: need_003
 539                version: "3.8"
 540                services:
 541                  app:
 542                    build: .
 543            """,
 544            "version:",
 545        ),
 546        (
 547            b"""
 548                items:
 549                  # @req-id: need_004
 550                  - name: item1
 551                    value: test
 552                  - name: item2
 553                    value: test2
 554            """,
 555            "- name: item1",
 556        ),
 557    ],
 558)
 559def test_find_associated_scope_yaml(code, result, init_yaml_tree_sitter):
 560    parser, query = init_yaml_tree_sitter
 561    comments = utils.extract_comments(code, parser, query)
 562    node: TreeSitterNode | None = utils.find_associated_scope(
 563        comments[0], CommentType.yaml
 564    )
 565    assert node
 566    assert node.text
 567    yaml_structure = node.text.decode("utf-8")
 568    assert result in yaml_structure
 569
 570
 571@pytest.mark.parametrize(
 572    ("code", "result"),
 573    [
 574        (
 575            b"""
 576                // @req-id: need_001
 577                fn dummy_func1() {
 578                }
 579            """,
 580            "fn dummy_func1()",
 581        ),
 582        (
 583            b"""
 584                fn dummy_func2() {
 585                }
 586                // @req-id: need_001
 587                fn dummy_func1() {
 588                }
 589            """,
 590            "fn dummy_func1()",
 591        ),
 592        (
 593            b"""
 594                fn dummy_func1() {
 595                    let a = 1;
 596                    /* @req-id: need_001 */
 597                }
 598            """,
 599            "fn dummy_func1()",
 600        ),
 601        (
 602            b"""
 603                fn dummy_func1() {
 604                    // @req-id: need_001
 605                    let a = 1;
 606                }
 607                fn dummy_func2() {
 608                }
 609            """,
 610            "fn dummy_func1()",
 611        ),
 612        (
 613            b"""
 614                /// @req-id: need_001
 615                fn dummy_func1() {
 616                }
 617            """,
 618            "fn dummy_func1()",
 619        ),
 620        (
 621            b"""
 622                struct MyStruct {
 623                    // @req-id: need_001
 624                    field: i32,
 625                }
 626            """,
 627            "struct MyStruct",
 628        ),
 629    ],
 630)
 631def test_find_associated_scope_rust(code, result, init_rust_tree_sitter):
 632    parser, query = init_rust_tree_sitter
 633    comments = utils.extract_comments(code, parser, query)
 634    node: TreeSitterNode | None = utils.find_associated_scope(
 635        comments[0], CommentType.rust
 636    )
 637    assert node
 638    assert node.text
 639    rust_def = node.text.decode("utf-8")
 640    assert result in rust_def
 641
 642
 643@pytest.mark.parametrize(
 644    ("code", "result"),
 645    [
 646        # leading comment is associated with the following key/value pair
 647        (
 648            b'{\n  // @req-id: need_001\n  "alpha": 1\n}\n',
 649            '"alpha": 1',
 650        ),
 651        # inline comment is associated with the array item on the same row
 652        (
 653            b'{\n  "items": [\n    "first", // @req-id: need_001\n    "second"\n  ]\n}\n',
 654            '"first"',
 655        ),
 656        # inline comment is associated with the pair on the same row
 657        (
 658            b'{\n  "alpha": 1, // @req-id: need_001\n  "beta": 2\n}\n',
 659            '"alpha": 1',
 660        ),
 661        # block comment is associated with the following pair
 662        (
 663            b'{\n  /* @req-id: need_001 */\n  "beta": 2\n}\n',
 664            '"beta": 2',
 665        ),
 666        # trailing comment falls back to the enclosing object
 667        (
 668            b'{\n  "alpha": 1\n  // @req-id: need_001\n}\n',
 669            '"alpha"',
 670        ),
 671    ],
 672)
 673def test_find_associated_scope_jsonc(code, result, init_jsonc_tree_sitter):
 674    parser, query = init_jsonc_tree_sitter
 675    comments = utils.extract_comments(code, parser, query)
 676    node: TreeSitterNode | None = utils.find_associated_scope(
 677        comments[0], CommentType.jsonc
 678    )
 679    assert node
 680    assert node.text
 681    jsonc_structure = node.text.decode("utf-8")
 682    assert result in jsonc_structure
 683
 684
 685@pytest.mark.parametrize(
 686    ("code", "result"),
 687    [
 688        # comment above a POSIX-style function definition
 689        (
 690            b"""
 691                # @req-id: need_001
 692                greet() {
 693                    echo hi
 694                }
 695            """,
 696            "greet()",
 697        ),
 698        # comment above the `function` keyword form
 699        (
 700            b"""
 701                # @req-id: need_002
 702                function greet {
 703                    echo hi
 704                }
 705            """,
 706            "function greet",
 707        ),
 708        # comment inside a function body falls back to the enclosing function
 709        (
 710            b"""
 711                greet() {
 712                    # @req-id: need_003
 713                    echo hi
 714                }
 715            """,
 716            "greet()",
 717        ),
 718    ],
 719)
 720def test_find_associated_scope_bash(code, result, init_bash_tree_sitter):
 721    parser, query = init_bash_tree_sitter
 722    comments = utils.extract_comments(code, parser, query)
 723    node: TreeSitterNode | None = utils.find_associated_scope(
 724        comments[0], CommentType.bash
 725    )
 726    assert node
 727    assert node.text
 728    func_def = node.text.decode("utf-8")
 729    assert func_def.startswith(result)
 730
 731
 732@pytest.mark.parametrize(
 733    ("code", "result"),
 734    [
 735        (
 736            b"""
 737                // @req-id: need_001
 738                function dummyFunc1() {
 739                }
 740            """,
 741            "function dummyFunc1()",
 742        ),
 743        (
 744            b"""
 745                class DummyClass {
 746                    // @req-id: need_001
 747                    method1() {
 748                    }
 749                }
 750            """,
 751            "method1()",
 752        ),
 753        # leading comment on an exported function must descend into
 754        # export_statement, not resolve to no scope
 755        (
 756            b"""
 757                // @req-id: need_001
 758                export function dummyFunc1() {
 759                }
 760            """,
 761            "function dummyFunc1()",
 762        ),
 763        # leading comment on an exported class
 764        (
 765            b"""
 766                // @req-id: need_001
 767                export class DummyClass {
 768                }
 769            """,
 770            "class DummyClass",
 771        ),
 772        # leading comment on an exported const arrow function
 773        (
 774            b"""
 775                // @req-id: need_001
 776                export const dummyFunc1 = () => {
 777                };
 778            """,
 779            "dummyFunc1",
 780        ),
 781        # arrow-function const still resolves to itself
 782        (
 783            b"""
 784                // @req-id: need_001
 785                const dummyFunc1 = () => {
 786                };
 787            """,
 788            "dummyFunc1",
 789        ),
 790        # JSX-returning component parses cleanly and resolves scope (.tsx content)
 791        (
 792            b"""
 793                // @req-id: need_001
 794                export function Button() {
 795                    return <button>Click me</button>;
 796                }
 797            """,
 798            "function Button()",
 799        ),
 800    ],
 801)
 802def test_find_associated_scope_typescript(code, result, init_typescript_tree_sitter):
 803    parser, query = init_typescript_tree_sitter
 804    comments = utils.extract_comments(code, parser, query)
 805    node: TreeSitterNode | None = utils.find_associated_scope(
 806        comments[0], CommentType.ts
 807    )
 808    assert node
 809    assert node.text
 810    ts_def = node.text.decode("utf-8")
 811    assert result in ts_def
 812
 813
 814@pytest.mark.parametrize(
 815    ("code", "result"),
 816    [
 817        (
 818            b"""
 819                def dummy_func1():
 820                    # @req-id: need_001
 821                    pass
 822            """,
 823            "def dummy_func1()",
 824        ),
 825        (
 826            b"""
 827                def dummy_func1():
 828                '''@req-id: need_001'''
 829                    pass
 830            """,
 831            "def dummy_func1()",
 832        ),
 833        (
 834            b"""
 835                def dummy_func1():
 836                    def nested_dummy_func1():
 837                        '''@req-id: need_001'''
 838                        pass
 839                    pass
 840            """,
 841            "def nested_dummy_func1()",
 842        ),
 843        (
 844            b"""
 845                def dummy_func1():
 846                    '''@req-id: need_001'''
 847                    def nested_dummy_func1():
 848                        pass
 849                    pass
 850            """,
 851            "def dummy_func1()",
 852        ),
 853    ],
 854)
 855def test_find_enclosing_scope_python(code, result, init_python_tree_sitter):
 856    parser, query = init_python_tree_sitter
 857    comments = utils.extract_comments(code, parser, query)
 858    node: TreeSitterNode | None = utils.find_enclosing_scope(
 859        comments[0], CommentType.python
 860    )
 861    assert node
 862    assert node.text
 863    func_def = node.text.decode("utf-8")
 864    assert result in func_def
 865
 866
 867@pytest.mark.parametrize(
 868    ("code", "result"),
 869    [
 870        (
 871            b"""
 872                # @req-id: need_001
 873                def dummy_func1():
 874                    pass
 875            """,
 876            "def dummy_func1()",
 877        ),
 878        (
 879            b"""
 880                # @req-id: need_001
 881                # @req-id: need_002
 882                def dummy_func1():
 883                    pass
 884            """,
 885            "def dummy_func1()",
 886        ),
 887    ],
 888)
 889def test_find_next_scope_python(code, result, init_python_tree_sitter):
 890    parser, query = init_python_tree_sitter
 891    comments = utils.extract_comments(code, parser, query)
 892    node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.python)
 893    assert node
 894    assert node.text
 895    func_def = node.text.decode("utf-8")
 896    assert result in func_def
 897
 898
 899@pytest.mark.parametrize(
 900    ("code", "result"),
 901    [
 902        (
 903            b"""
 904                // @req-id: need_001
 905                void dummy_func1(){
 906                }
 907            """,
 908            "void dummy_func1()",
 909        ),
 910        (
 911            b"""
 912                /* @req-id: need_001 */
 913                void dummy_func1(){
 914                }
 915            """,
 916            "void dummy_func1()",
 917        ),
 918    ],
 919)
 920def test_find_next_scope_cpp(code, result, init_cpp_tree_sitter):
 921    parser, query = init_cpp_tree_sitter
 922    comments = utils.extract_comments(code, parser, query)
 923    node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.cpp)
 924    assert node
 925    assert node.text
 926    func_def = node.text.decode("utf-8")
 927    assert result in func_def
 928
 929
 930@pytest.mark.parametrize(
 931    ("code", "result"),
 932    [
 933        (
 934            b"""
 935                // @req-id: need_001
 936                public class DummyClass1
 937                {
 938                }
 939            """,
 940            "public class DummyClass1",
 941        ),
 942        (
 943            b"""
 944
 945                public class DummyClass1
 946                {
 947                    /* @req-id: need_001 */
 948                    /* @req-id: need_002 */
 949                    public void DummyFunc1()
 950                    {
 951                    }
 952                }
 953            """,
 954            "public void DummyFunc1",
 955        ),
 956    ],
 957)
 958def test_find_next_scope_csharp(code, result, init_csharp_tree_sitter):
 959    parser, query = init_csharp_tree_sitter
 960    comments = utils.extract_comments(code, parser, query)
 961    node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.cs)
 962    assert node
 963    assert node.text
 964    func_def = node.text.decode("utf-8")
 965    assert result in func_def
 966
 967
 968@pytest.mark.parametrize(
 969    ("code", "result"),
 970    [
 971        (
 972            b"""
 973                // @req-id: need_001
 974                function dummyFunc1() {
 975                }
 976            """,
 977            "function dummyFunc1()",
 978        ),
 979        (
 980            b"""
 981                // @req-id: need_001
 982                export function dummyFunc1() {
 983                }
 984            """,
 985            "function dummyFunc1()",
 986        ),
 987    ],
 988)
 989def test_find_next_scope_typescript(code, result, init_typescript_tree_sitter):
 990    parser, query = init_typescript_tree_sitter
 991    comments = utils.extract_comments(code, parser, query)
 992    node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.ts)
 993    assert node
 994    assert node.text
 995    func_def = node.text.decode("utf-8")
 996    assert result in func_def
 997
 998
 999def test_a_plain_const_breaks_adjacency_typescript(init_typescript_tree_sitter):
1000    """A plain (non-function) const between the comment and a function is code, not
1001    trivia: the comment is attached to neither, here to the file."""
1002    code = b"""
1003        // @req-id: need_001
1004        const helperFlag = true;
1005        function dummyFunc1() {
1006        }
1007    """
1008    parser, query = init_typescript_tree_sitter
1009    comments = utils.extract_comments(code, parser, query)
1010    assert comments
1011    assert utils.find_next_scope(comments[0], CommentType.ts) is None
1012    assert utils.find_associated_scope(comments[0], CommentType.ts) is None
1013
1014
1015def test_find_associated_scope_typescript_jsx_no_parse_error(
1016    init_typescript_tree_sitter,
1017):
1018    """A JSX-returning component must parse cleanly under the TSX grammar."""
1019    code = b"""
1020        // @req-id: need_001
1021        export function Button() {
1022            return <button>Click me</button>;
1023        }
1024    """
1025    parser, _ = init_typescript_tree_sitter
1026    tree = parser.parse(code)
1027    assert not tree.root_node.has_error
1028
1029
1030@pytest.mark.parametrize(
1031    ("name", "grammar"),
1032    [
1033        ("a.ts", "typescript"),
1034        ("a.mts", "typescript"),
1035        ("a.cts", "typescript"),
1036        ("A.TS", "typescript"),
1037        ("a.Mts", "typescript"),
1038        # a declaration file's suffix is ``.ts``: TypeScript, as any ``.ts`` file
1039        ("a.d.ts", "typescript"),
1040        ("a.tsx", "tsx"),
1041        ("a.js", "tsx"),
1042        ("a.jsx", "tsx"),
1043        ("a.mjs", "tsx"),
1044        ("a.cjs", "tsx"),
1045    ],
1046)
1047def test_ts_grammar_key(name: str, grammar: str) -> None:
1048    """TypeScript's own suffixes, in any case, get the TypeScript grammar, where a
1049    ``<T>x`` cast is legal; every other suffix of the family gets TSX."""
1050    assert utils.ts_grammar_key(Path(name)) == grammar
1051
1052
1053def test_typescript_ts_suffix_recovers_markers_around_angle_bracket_cast():
1054    """A ``.ts`` file must use the plain TypeScript grammar, not TSX.
1055
1056    ``<string>x`` is a legacy angle-bracket type assertion: valid TypeScript
1057    syntax, but JSX syntax under the TSX grammar. There it parses as a
1058    ``jsx_opening_element`` and swallows the rest of the file into a single
1059    ``jsx_text`` node, silently dropping every marker after it (``has_error``
1060    is also set). ``init_tree_sitter`` must pick the plain TypeScript grammar
1061    for a ``.ts`` path, via ``ts_grammar_key``, so markers both above and
1062    below the cast all survive.
1063    """
1064    code = b"""// @Top, IMPL_TOP
1065const v = <string>x;
1066// @Bottom, IMPL_BOTTOM
1067const y = 2;
1068// @Third, IMPL_THIRD
1069"""
1070    parser, query = utils.init_tree_sitter(CommentType.ts, Path("dummy.ts"))
1071    tree = parser.parse(code)
1072    assert not tree.root_node.has_error
1073
1074    comments = utils.extract_comments(code, parser, query)
1075    assert comments is not None
1076    comments.sort(key=lambda node: node.start_point.row)
1077    texts = [node.text.decode("utf-8") for node in comments if node.text]
1078    assert texts == [
1079        "// @Top, IMPL_TOP",
1080        "// @Bottom, IMPL_BOTTOM",
1081        "// @Third, IMPL_THIRD",
1082    ]
1083
1084
1085@pytest.mark.parametrize(
1086    ("code", "result"),
1087    [
1088        (
1089            b"""
1090                void dummy_func1(){
1091                // @req-id: need_001
1092                }
1093            """,
1094            "void dummy_func1()",
1095        ),
1096        (
1097            b"""
1098                void dummy_func1(){
1099                /* @req-id: need_001 */
1100                }
1101            """,
1102            "void dummy_func1()",
1103        ),
1104    ],
1105)
1106def test_find_enclosing_scope_cpp(code, result, init_cpp_tree_sitter):
1107    parser, query = init_cpp_tree_sitter
1108    comments = utils.extract_comments(code, parser, query)
1109    node: TreeSitterNode | None = utils.find_enclosing_scope(
1110        comments[0], CommentType.cpp
1111    )
1112    assert node
1113    assert node.text
1114    func_def = node.text.decode("utf-8")
1115    assert result in func_def
1116
1117
1118@pytest.mark.parametrize(
1119    ("code", "result"),
1120    [
1121        (
1122            b"""
1123                public class DummyClass1
1124                {
1125                    // @req-id: need_001
1126                }
1127            """,
1128            "public class DummyClass1",
1129        ),
1130        (
1131            b"""
1132                public class DummyClass1
1133                {
1134                    public void DummyFunc1()
1135                    {
1136                        /* @req-id: need_001 */
1137                    }
1138                }
1139            """,
1140            "public void DummyFunc1()",
1141        ),
1142        (
1143            b"""
1144                public class DummyClass1
1145                {
1146                    public string DummyProperty1
1147                    {
1148                        get
1149                        {
1150                            /* @req-id: need_001 */
1151                            return "dummy";
1152                        }
1153                    }
1154                }
1155            """,
1156            "public string DummyProperty1",
1157        ),
1158    ],
1159)
1160def test_find_enclosing_scope_csharp(code, result, init_csharp_tree_sitter):
1161    parser, query = init_csharp_tree_sitter
1162    comments = utils.extract_comments(code, parser, query)
1163    node: TreeSitterNode | None = utils.find_enclosing_scope(
1164        comments[0], CommentType.cs
1165    )
1166    assert node
1167    assert node.text
1168    func_def = node.text.decode("utf-8")
1169    assert result in func_def
1170
1171
1172@pytest.mark.parametrize(
1173    ("code", "num_comments", "result"),
1174    [
1175        (
1176            b"""
1177                // @req-id: need_001
1178                void dummy_func1(){
1179                }
1180            """,
1181            1,
1182            "// @req-id: need_001",
1183        ),
1184        (
1185            b"""
1186                void dummy_func1(){
1187                // @req-id: need_001
1188                }
1189            """,
1190            1,
1191            "// @req-id: need_001",
1192        ),
1193        (
1194            b"""
1195                /* @req-id: need_001 */
1196                void dummy_func1(){
1197                }
1198            """,
1199            1,
1200            "/* @req-id: need_001 */",
1201        ),
1202        (
1203            b"""
1204                //  @req-id: need_001
1205                //
1206                //
1207                void dummy_func1(){
1208                }
1209            """,
1210            3,
1211            "//  @req-id: need_001",
1212        ),
1213    ],
1214)
1215def test_cpp_comment(code, num_comments, result, init_cpp_tree_sitter):
1216    parser, query = init_cpp_tree_sitter
1217    comments = utils.extract_comments(code, parser, query)
1218    assert len(comments) == num_comments
1219    comments.sort(key=lambda x: x.start_point.row)
1220    assert comments[0].text
1221    assert comments[0].text.decode("utf-8") == result
1222
1223
1224@pytest.mark.parametrize(
1225    ("code", "num_comments", "result"),
1226    [
1227        (
1228            b"""
1229                # @req-id: need_001
1230                def dummy_func1():
1231                    pass
1232            """,
1233            1,
1234            "# @req-id: need_001",
1235        ),
1236        (
1237            b"""
1238                def dummy_func1():
1239                    # @req-id: need_001
1240                    pass
1241            """,
1242            1,
1243            "# @req-id: need_001",
1244        ),
1245        (
1246            b"""
1247                # single line comment
1248                # @req-id: need_001
1249                def dummy_func1():
1250                    pass
1251            """,
1252            2,
1253            "# single line comment",
1254        ),
1255        (
1256            b"""
1257                def dummy_func1():
1258                    '''
1259                    @req-id: need_001
1260                    '''
1261                    pass
1262            """,
1263            1,
1264            "'''\n                    @req-id: need_001\n                    '''",
1265        ),
1266        (
1267            b"""
1268                def dummy_func1():
1269                    text = '''@req-id: need_001, need_002, this docstring shall not be extracted as comment'''
1270                    # @req-id: need_001
1271                    pass
1272            """,
1273            1,
1274            "# @req-id: need_001",
1275        ),
1276    ],
1277)
1278def test_python_comment(code, num_comments, result, init_python_tree_sitter):
1279    parser, query = init_python_tree_sitter
1280    comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1281    comments.sort(key=lambda x: x.start_point.row)
1282    assert len(comments) == num_comments
1283    assert comments[0].text
1284    assert comments[0].text.decode("utf-8") == result
1285
1286
1287@pytest.mark.parametrize(
1288    ("code", "num_comments", "result"),
1289    [
1290        (
1291            b"""
1292                // @req-id: need_001
1293                void DummyFunc1(){
1294                }
1295            """,
1296            1,
1297            "// @req-id: need_001",
1298        ),
1299        (
1300            b"""
1301                void DummyFunc1(){
1302                // @req-id: need_001
1303                }
1304            """,
1305            1,
1306            "// @req-id: need_001",
1307        ),
1308        (
1309            b"""
1310                /* @req-id: need_001 */
1311                void DummyFunc1(){
1312                }
1313            """,
1314            1,
1315            "/* @req-id: need_001 */",
1316        ),
1317        (
1318            b"""
1319                //  @req-id: need_001
1320                //
1321                //
1322                void DummyFunc1(){
1323                }
1324            """,
1325            3,
1326            "//  @req-id: need_001",
1327        ),
1328    ],
1329)
1330def test_csharp_comment(code, num_comments, result, init_csharp_tree_sitter):
1331    parser, query = init_csharp_tree_sitter
1332    comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1333    comments.sort(key=lambda x: x.start_point.row)
1334    assert len(comments) == num_comments
1335    assert comments[0].text
1336    assert comments[0].text.decode("utf-8") == result
1337
1338
1339@pytest.mark.parametrize(
1340    ("code", "num_comments", "result"),
1341    [
1342        (
1343            b"""
1344                // @req-id: need_001
1345                function dummyFunc1() {
1346                }
1347            """,
1348            1,
1349            "// @req-id: need_001",
1350        ),
1351        (
1352            b"""
1353                /* @req-id: need_001 */
1354                const value = 1;
1355            """,
1356            1,
1357            "/* @req-id: need_001 */",
1358        ),
1359    ],
1360)
1361def test_typescript_comment(code, num_comments, result, init_typescript_tree_sitter):
1362    parser, query = init_typescript_tree_sitter
1363    comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1364    comments.sort(key=lambda x: x.start_point.row)
1365    assert len(comments) == num_comments
1366    assert comments[0].text
1367    assert comments[0].text.decode("utf-8") == result
1368
1369
1370@pytest.mark.parametrize(
1371    ("code", "num_comments", "result"),
1372    [
1373        (
1374            b"""
1375                # @req-id: need_001
1376                database:
1377                  host: localhost
1378            """,
1379            1,
1380            "# @req-id: need_001",
1381        ),
1382        (
1383            b"""
1384                services:
1385                  web:
1386                    # @req-id: need_001
1387                    image: nginx:latest
1388            """,
1389            1,
1390            "# @req-id: need_001",
1391        ),
1392        (
1393            b"""
1394                # Top level comment
1395                # @req-id: need_001
1396                version: "3.8"
1397            """,
1398            2,
1399            "# Top level comment",
1400        ),
1401    ],
1402)
1403def test_yaml_comment(code, num_comments, result, init_yaml_tree_sitter):
1404    parser, query = init_yaml_tree_sitter
1405    comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1406    comments.sort(key=lambda x: x.start_point.row)
1407    assert len(comments) == num_comments
1408    assert comments[0].text
1409    assert comments[0].text.decode("utf-8") == result
1410
1411
1412@pytest.mark.parametrize(
1413    ("code", "num_comments", "result"),
1414    [
1415        (
1416            b"""
1417                // @req-id: need_001
1418                func dummyFunc1() {
1419                }
1420            """,
1421            1,
1422            "// @req-id: need_001",
1423        ),
1424        (
1425            b"""
1426                func dummyFunc1() {
1427                // @req-id: need_001
1428                }
1429            """,
1430            1,
1431            "// @req-id: need_001",
1432        ),
1433        (
1434            b"""
1435                /* @req-id: need_001 */
1436                func dummyFunc1() {
1437                }
1438            """,
1439            1,
1440            "/* @req-id: need_001 */",
1441        ),
1442        (
1443            b"""
1444                //  @req-id: need_001
1445                //
1446                //
1447                func dummyFunc1() {
1448                }
1449            """,
1450            3,
1451            "//  @req-id: need_001",
1452        ),
1453    ],
1454)
1455def test_go_comment(code, num_comments, result, init_go_tree_sitter):
1456    parser, query = init_go_tree_sitter
1457    comments: list[TreeSitterNode] = utils.extract_comments(code, parser, query)
1458    comments.sort(key=lambda x: x.start_point.row)
1459    assert len(comments) == num_comments
1460    assert comments[0].text
1461    assert comments[0].text.decode("utf-8") == result
1462
1463
1464@pytest.mark.parametrize(
1465    ("code", "result"),
1466    [
1467        (
1468            b"""
1469                // @req-id: need_001
1470                func dummyFunc1() {
1471                }
1472            """,
1473            "func dummyFunc1()",
1474        ),
1475        (
1476            b"""
1477                func dummyFunc2() {
1478                }
1479                // @req-id: need_001
1480                func dummyFunc1() {
1481                }
1482            """,
1483            "func dummyFunc1()",
1484        ),
1485        (
1486            b"""
1487                /* @req-id: need_001 */
1488                func dummyFunc1() {
1489                }
1490            """,
1491            "func dummyFunc1()",
1492        ),
1493    ],
1494)
1495def test_find_associated_scope_go(code, result, init_go_tree_sitter):
1496    parser, query = init_go_tree_sitter
1497    comments = utils.extract_comments(code, parser, query)
1498    node: TreeSitterNode | None = utils.find_associated_scope(
1499        comments[0], CommentType.go
1500    )
1501    assert node
1502    assert node.text
1503    go_def = node.text.decode("utf-8")
1504    assert result in go_def
1505
1506
1507@pytest.mark.parametrize(
1508    ("code", "result"),
1509    [
1510        (
1511            b"""
1512                // @req-id: need_001
1513                func dummyFunc1() {
1514                }
1515            """,
1516            "func dummyFunc1()",
1517        ),
1518        (
1519            b"""
1520                // @req-id: need_001
1521                type DummyStruct struct {
1522                    Field int
1523                }
1524            """,
1525            "type DummyStruct struct",
1526        ),
1527    ],
1528)
1529def test_find_next_scope_go(code, result, init_go_tree_sitter):
1530    parser, query = init_go_tree_sitter
1531    comments = utils.extract_comments(code, parser, query)
1532    node: TreeSitterNode | None = utils.find_next_scope(comments[0], CommentType.go)
1533    assert node
1534    assert node.text
1535    go_def = node.text.decode("utf-8")
1536    assert result in go_def
1537
1538
1539@pytest.mark.parametrize(
1540    ("code", "result"),
1541    [
1542        (
1543            b"""
1544                func dummyFunc1() {
1545                    // @req-id: need_001
1546                }
1547            """,
1548            "func dummyFunc1()",
1549        ),
1550        (
1551            b"""
1552                func dummyFunc1() {
1553                    /* @req-id: need_001 */
1554                }
1555            """,
1556            "func dummyFunc1()",
1557        ),
1558    ],
1559)
1560def test_find_enclosing_scope_go(code, result, init_go_tree_sitter):
1561    parser, query = init_go_tree_sitter
1562    comments = utils.extract_comments(code, parser, query)
1563    node: TreeSitterNode | None = utils.find_enclosing_scope(
1564        comments[0], CommentType.go
1565    )
1566    assert node
1567    assert node.text
1568    go_def = node.text.decode("utf-8")
1569    assert result in go_def
1570
1571
1572@pytest.mark.parametrize(
1573    ("git_url", "rev", "project_path", "filepath", "lineno", "result"),
1574    [
1575        (
1576            "git@github.com:useblocks/sphinx-codelinks.git",
1577            "beef1234",
1578            Path(__file__).parent.parent,
1579            # ABSOLUTE, and under `project_path`. `form_https_url` resolves the
1580            # filepath with `.absolute()`, i.e. against the CWD, so a relative one
1581            # here only agrees with `project_path` when pytest happens to run from
1582            # the package directory -- which it does not when the whole workspace's
1583            # suite is invoked from the repository root
1584            Path(__file__).parent.parent / "example" / "to" / "here",
1585            3,
1586            "https://github.com/useblocks/sphinx-codelinks/blob/beef1234/example/to/here#L3",
1587        )
1588    ],
1589)
1590def test_form_https_url(
1591    git_url, rev, project_path, filepath, lineno, result
1592):  # need to have these args
1593    url = utils.form_https_url(git_url, rev, project_path, filepath, lineno=lineno)
1594    assert url == result
1595
1596
1597def get_git_path() -> str:
1598    """Get the path to the git executable."""
1599    git_path = shutil.which("git")
1600    if not git_path:
1601        raise FileNotFoundError("Git executable not found")
1602    if not Path(git_path).is_file():
1603        raise FileNotFoundError("Git executable path is invalid")
1604    return git_path
1605
1606
1607def init_git_repo(repo_path: Path, remote_url: str) -> Path:
1608    """Initialize a git repository for testing."""
1609    git_dir = repo_path / "test_repo"
1610    src_dir = git_dir / "src"
1611    src_dir.mkdir(parents=True)
1612
1613    git_path = get_git_path()
1614    if not git_path:
1615        raise FileNotFoundError("Git executable not found")
1616    if not Path(git_path).is_file():
1617        raise FileNotFoundError("Git executable path is invalid")
1618
1619    # Initialize git repo
1620    subprocess.run([git_path, "init"], cwd=git_dir, check=True, capture_output=True)
1621    subprocess.run(
1622        [git_path, "config", "user.email", "test@example.com"], cwd=git_dir, check=True
1623    )
1624    subprocess.run(
1625        [git_path, "config", "user.name", "Test User"], cwd=git_dir, check=True
1626    )
1627
1628    # Create a test file and commit
1629    test_file = src_dir / "test_file.py"
1630    test_file.write_text("# Test file\nprint('hello')\n")
1631    subprocess.run([git_path, "add", "."], cwd=git_dir, check=True)
1632    subprocess.run(
1633        [git_path, "commit", "-m", "Initial commit"], cwd=git_dir, check=True
1634    )
1635
1636    # Add a remote
1637    subprocess.run(
1638        [git_path, "remote", "add", "origin", remote_url],
1639        cwd=git_dir,
1640        check=True,
1641    )
1642
1643    return git_dir
1644
1645
1646@pytest.fixture(
1647    params=[
1648        ("test_repo_git", "git@github.com:test-user/test-repo.git"),
1649        ("test_repo_https", "https://github.com/test-user/test-repo.git"),
1650    ]
1651)
1652def git_repo(tmp_path: str, request: pytest.FixtureRequest) -> tuple[Path, str]:
1653    """Create git repos for testing."""
1654    repo_name, remote_url = request.param
1655    repo_path = Path(tmp_path) / repo_name
1656    repo_path = init_git_repo(repo_path, remote_url)
1657    return repo_path, remote_url
1658
1659
1660def get_current_commit_hash(git_dir: Path) -> str:
1661    """Get the current commit hash of the git repository."""
1662    git_path = get_git_path()
1663    result = subprocess.run(
1664        [git_path, "rev-parse", "HEAD"],
1665        cwd=git_dir,
1666        check=True,
1667        capture_output=True,
1668        text=True,
1669    )
1670
1671    return str(result.stdout.strip())
1672
1673
1674def test_locate_git_root(git_repo: tuple[Path, str]) -> None:
1675    repo_path = git_repo[0]
1676    src_dir = repo_path / "src"
1677    git_root = utils.locate_git_root(src_dir)
1678    assert git_root == repo_path
1679
1680
1681def test_get_remote_url(git_repo: tuple[Path, str]) -> None:
1682    repo_path, expected_url = git_repo
1683    remote_url = utils.get_remote_url(repo_path)
1684    assert remote_url == expected_url
1685
1686
1687def test_get_current_rev(git_repo: tuple[Path, str]) -> None:
1688    repo_path, _ = git_repo
1689    current_rev = get_current_commit_hash(repo_path)
1690    assert current_rev == utils.get_current_rev(repo_path)
1691
1692
1693@pytest.fixture(params=["detached", "branch"])
1694def git_worktree(
1695    tmp_path: Path, request: pytest.FixtureRequest
1696) -> tuple[Path, str, str]:
1697    """A LINKED WORKTREE of a scratch repository, in both shapes git makes.
1698
1699    A worktree is not a checkout with a `.git` directory: `.git` is a FILE holding
1700    `gitdir: <path>`, `config` lives in the main repository's git directory (named by
1701    the worktree's `commondir`), and only per-worktree state -- `HEAD` among it -- is in
1702    the worktree's own. `detached` puts a SHA in `HEAD`; `branch` puts `ref:
1703    refs/heads/...`, whose target is a shared ref in the common directory.
1704
1705    This repository is worked in worktrees as a matter of routine, so a helper that
1706    demands a `.git` directory fails for every developer while staying green in CI
1707    (useblocks/sphinx-codelinks#106).
1708    """
1709    remote_url = "https://github.com/test-user/test-repo.git"
1710    repo_path = init_git_repo(tmp_path / "main", remote_url)
1711    git_path = get_git_path()
1712    worktree_path = tmp_path / "worktree"
1713    add = [git_path, "worktree", "add"]
1714    add += (
1715        ["--detach", str(worktree_path), "HEAD"]
1716        if request.param == "detached"
1717        else [str(worktree_path), "-b", "a-worktree-branch"]
1718    )
1719    subprocess.run(add, cwd=repo_path, check=True, capture_output=True)
1720    assert (worktree_path / ".git").is_file(), "a linked worktree's .git is a file"
1721    return worktree_path, remote_url, get_current_commit_hash(repo_path)
1722
1723
1724def test_locate_git_root_worktree(git_worktree: tuple[Path, str, str]) -> None:
1725    worktree_path, _, _ = git_worktree
1726    assert utils.locate_git_root(worktree_path / "src") == worktree_path
1727
1728
1729def test_get_remote_url_worktree(git_worktree: tuple[Path, str, str]) -> None:
1730    worktree_path, remote_url, _ = git_worktree
1731    assert utils.get_remote_url(worktree_path) == remote_url
1732
1733
1734def test_get_current_rev_worktree(git_worktree: tuple[Path, str, str]) -> None:
1735    worktree_path, _, rev = git_worktree
1736    assert utils.get_current_rev(worktree_path) == rev
1737
1738
1739def test_get_current_rev_packed_refs(git_worktree: tuple[Path, str, str]) -> None:
1740    """`git gc` packs the loose refs away, and the branch ref is then in `packed-refs`.
1741
1742    Nothing unusual has to happen for this: `git gc` runs unattended on any long-lived
1743    checkout, and afterwards there is no `refs/heads/<branch>` FILE in either the
1744    worktree's git directory or the common one. It is the same failure shape as
1745    useblocks/sphinx-codelinks#106 reached by a different route -- and CI never sees it,
1746    because `actions/checkout` and Read the Docs both leave a detached HEAD.
1747    """
1748    worktree_path, _, rev = git_worktree
1749    git_path = get_git_path()
1750    subprocess.run(
1751        [git_path, "pack-refs", "--all"],
1752        cwd=worktree_path,
1753        check=True,
1754        capture_output=True,
1755    )
1756    git_dir = utils._git_dir(worktree_path)
1757    assert git_dir is not None
1758    common = utils._git_common_dir(git_dir)
1759    assert not list((common / "refs" / "heads").glob("*")), (
1760        "the loose refs should be gone"
1761    )
1762
1763    assert utils.get_current_rev(worktree_path) == rev
1764
1765
1766def test_get_current_rev_detached_head(tmp_path: Path) -> None:
1767    """In a detached HEAD (e.g. CI checkouts) .git/HEAD holds the commit SHA
1768    directly; get_current_rev returns it rather than warning and giving up."""
1769    git_root = tmp_path / "repo"
1770    (git_root / ".git").mkdir(parents=True)
1771    sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"
1772    (git_root / ".git" / "HEAD").write_text(f"{sha}\n")
1773
1774    assert utils.get_current_rev(git_root) == sha
1775
1776
1777# ========== YAML-specific tests ==========
1778
1779
1780@pytest.mark.parametrize(
1781    ("code", "expected_structure"),
1782    [
1783        # Basic key-value pair
1784        (
1785            b"""
1786                # Comment before key
1787                database:
1788                  host: localhost
1789            """,
1790            "database:",
1791        ),
1792        # Comment in nested structure
1793        (
1794            b"""
1795                services:
1796                  web:
1797                    # Comment before image
1798                    image: nginx:latest
1799            """,
1800            "image: nginx:latest",
1801        ),
1802        # Comment before list item
1803        (
1804            b"""
1805                items:
1806                  # Comment before list item
1807                  - name: item1
1808                    value: test
1809            """,
1810            "- name: item1",
1811        ),
1812        # Comment in document structure
1813        (
1814            b"""---
1815# Comment in document
1816version: "3.8"
1817services:
1818  app:
1819    build: .
1820            """,
1821            "version:",
1822        ),
1823        # Flow mapping structure
1824        (
1825            b"""
1826                # Comment before flow mapping
1827                config: {debug: true, port: 8080}
1828            """,
1829            "config:",
1830        ),
1831    ],
1832)
1833def test_find_yaml_next_structure(code, expected_structure, init_yaml_tree_sitter):
1834    """Test the find_yaml_next_structure function."""
1835    parser, query = init_yaml_tree_sitter
1836    comments = utils.extract_comments(code, parser, query)
1837    assert comments, "No comments found in the code"
1838
1839    next_structure = utils.find_yaml_next_structure(comments[0])
1840    assert next_structure, "No next structure found"
1841    structure_text = next_structure.text.decode("utf-8")
1842    assert expected_structure in structure_text
1843
1844
1845@pytest.mark.parametrize(
1846    ("code", "expected_structure"),
1847    [
1848        # Comment associated with key-value pair
1849        (
1850            b"""
1851                # Database configuration
1852                database:
1853                  host: localhost
1854                  port: 5432
1855            """,
1856            "database:",
1857        ),
1858        # Comment associated with nested structure
1859        (
1860            b"""
1861                services:
1862                  web:
1863                    # Web service image
1864                    image: nginx:latest
1865                    ports:
1866                      - "80:80"
1867            """,
1868            "image: nginx:latest",
1869        ),
1870        # Comment associated with list item
1871        (
1872            b"""
1873                dependencies:
1874                  # First dependency
1875                  - name: redis
1876                    version: "6.0"
1877                  - name: postgres
1878                    version: "13"
1879            """,
1880            "- name: redis",
1881        ),
1882        # Comment inside parent structure
1883        (
1884            b"""
1885                app:
1886                  # Internal comment
1887                  name: myapp
1888                  version: "1.0"
1889            """,
1890            "name: myapp",
1891        ),
1892    ],
1893)
1894def test_find_yaml_associated_structure(
1895    code, expected_structure, init_yaml_tree_sitter
1896):
1897    """Test the find_yaml_associated_structure function."""
1898    parser, query = init_yaml_tree_sitter
1899    comments = utils.extract_comments(code, parser, query)
1900    assert comments, "No comments found in the code"
1901
1902    associated_structure = utils.find_yaml_associated_structure(comments[0])
1903    assert associated_structure, "No associated structure found"
1904    structure_text = associated_structure.text.decode("utf-8")
1905    assert expected_structure in structure_text
1906
1907
1908@pytest.mark.parametrize(
1909    ("code", "expected_results"),
1910    [
1911        # Multiple comments in sequence
1912        (
1913            b"""
1914                # First comment
1915                # Second comment
1916                database:
1917                  host: localhost
1918            """,
1919            ["database:", "database:"],  # Both comments should associate with database
1920        ),
1921        # Comments at different nesting levels
1922        (
1923            b"""
1924                # Top level comment
1925                services:
1926                  web:
1927                    # Nested comment
1928                    image: nginx:latest
1929            """,
1930            ["services:", "image: nginx:latest"],
1931        ),
1932    ],
1933)
1934def test_multiple_yaml_comments(code, expected_results, init_yaml_tree_sitter):
1935    """Test handling of multiple YAML comments in the same file."""
1936    parser, query = init_yaml_tree_sitter
1937    comments = utils.extract_comments(code, parser, query)
1938    comments.sort(key=lambda x: x.start_point.row)
1939
1940    assert len(comments) == len(expected_results), (
1941        f"Expected {len(expected_results)} comments, found {len(comments)}"
1942    )
1943
1944    for i, comment in enumerate(comments):
1945        associated_structure = utils.find_yaml_associated_structure(comment)
1946        assert associated_structure, f"No associated structure found for comment {i}"
1947        structure_text = associated_structure.text.decode("utf-8")
1948        assert expected_results[i] in structure_text
1949
1950
1951@pytest.mark.parametrize(
1952    ("code", "has_structure"),
1953    [
1954        # Comment at end of file with no following structure
1955        (
1956            b"""
1957database:
1958  host: localhost
1959# End of file comment
1960            """,
1961            True,  # This will actually find the parent database structure
1962        ),
1963        # Comment with only whitespace after
1964        (
1965            b"""
1966                # Lonely comment
1967
1968
1969            """,
1970            False,
1971        ),
1972        # Comment before valid structure
1973        (
1974            b"""
1975                # Valid comment
1976                key: value
1977            """,
1978            True,
1979        ),
1980    ],
1981)
1982def test_yaml_edge_cases(code, has_structure, init_yaml_tree_sitter):
1983    """Test edge cases in YAML comment processing."""
1984    parser, query = init_yaml_tree_sitter
1985    comments = utils.extract_comments(code, parser, query)
1986
1987    if comments:
1988        structure = utils.find_yaml_associated_structure(comments[0])
1989        if has_structure:
1990            assert structure, "Expected to find associated structure"
1991        else:
1992            assert structure is None, "Expected no associated structure"
1993    else:
1994        assert not has_structure, "No comments found but structure was expected"
1995
1996
1997@pytest.mark.parametrize(
1998    ("code", "expected_structures"),
1999    [
2000        # Simpler nested YAML structure
2001        (
2002            b"""# Global configuration
2003version: "3.8"
2004
2005# Services section
2006services:
2007  web:
2008    image: nginx:latest
2009    # Port configuration
2010    ports:
2011      - "80:80"
2012            """,
2013            [
2014                "version:",  # Global configuration
2015                "services:",  # Services section
2016                '- "80:80"',  # Port configuration
2017            ],
2018        ),
2019    ],
2020)
2021def test_complex_yaml_structure(code, expected_structures, init_yaml_tree_sitter):
2022    """Test complex nested YAML structures with multiple comments."""
2023    parser, query = init_yaml_tree_sitter
2024    comments = utils.extract_comments(code, parser, query)
2025    comments.sort(key=lambda x: x.start_point.row)
2026
2027    assert len(comments) == len(expected_structures), (
2028        f"Expected {len(expected_structures)} comments, found {len(comments)}"
2029    )
2030
2031    for i, comment in enumerate(comments):
2032        associated_structure = utils.find_yaml_associated_structure(comment)
2033        assert associated_structure, f"No associated structure found for comment {i}"
2034        structure_text = associated_structure.text.decode("utf-8")
2035        assert expected_structures[i] in structure_text, (
2036            f"Expected '{expected_structures[i]}' in structure text: '{structure_text}'"
2037        )
2038
2039
2040@pytest.mark.parametrize(
2041    ("code", "expected_type"),
2042    [
2043        # Block mapping pair
2044        (
2045            b"""
2046                # Comment
2047                key: value
2048            """,
2049            "block_mapping_pair",
2050        ),
2051        # Block sequence item
2052        (
2053            b"""
2054                items:
2055                  # Comment
2056                  - item1
2057            """,
2058            "block_sequence_item",
2059        ),
2060        # Nested block mapping
2061        (
2062            b"""
2063                services:
2064                  # Comment
2065                  web:
2066                    image: nginx
2067            """,
2068            "block_mapping_pair",
2069        ),
2070    ],
2071)
2072def test_yaml_structure_types(code, expected_type, init_yaml_tree_sitter):
2073    """Test that YAML structures return the correct node types."""
2074    parser, query = init_yaml_tree_sitter
2075    comments = utils.extract_comments(code, parser, query)
2076    assert comments, "No comments found"
2077
2078    structure = utils.find_yaml_associated_structure(comments[0])
2079    assert structure, "No associated structure found"
2080    assert structure.type == expected_type, (
2081        f"Expected type {expected_type}, got {structure.type}"
2082    )
2083
2084
2085def test_yaml_document_structure(init_yaml_tree_sitter):
2086    """Test YAML document structure handling."""
2087    code = b"""---
2088# Document comment
2089apiVersion: v1
2090kind: ConfigMap
2091metadata:
2092  name: my-config
2093data:
2094  # Data comment
2095  config.yml: |
2096    setting: value
2097    """
2098
2099    parser, query = init_yaml_tree_sitter
2100    comments = utils.extract_comments(code, parser, query)
2101    comments.sort(key=lambda x: x.start_point.row)
2102
2103    # Should find both comments
2104    assert len(comments) >= 2, f"Expected at least 2 comments, found {len(comments)}"
2105
2106    # First comment should associate with apiVersion
2107    first_structure = utils.find_yaml_associated_structure(comments[0])
2108    assert first_structure, "No structure found for first comment"
2109    first_text = first_structure.text.decode("utf-8")
2110    assert "apiVersion:" in first_text
2111
2112    # Second comment should associate with config.yml
2113    second_structure = utils.find_yaml_associated_structure(comments[1])
2114    assert second_structure, "No structure found for second comment"
2115    second_text = second_structure.text.decode("utf-8")
2116    assert "config.yml:" in second_text
2117
2118
2119def test_yaml_inline_comments_current_behavior(init_yaml_tree_sitter):
2120    """Test improved behavior of inline comments in YAML after the fix."""
2121    code = b"""key1: value1  # inline comment about key1
2122key2: value2
2123key3: value3  # inline comment about key3
2124"""
2125
2126    parser, query = init_yaml_tree_sitter
2127    comments = utils.extract_comments(code, parser, query)
2128    comments.sort(key=lambda x: x.start_point.row)
2129
2130    assert len(comments) == 2, f"Expected 2 comments, found {len(comments)}"
2131
2132    # Fixed behavior: inline comment about key1 now correctly associates with key1
2133    first_structure = utils.find_yaml_associated_structure(comments[0])
2134    assert first_structure, "No structure found for first comment"
2135    first_text = first_structure.text.decode("utf-8")
2136    assert "key1:" in first_text, f"Expected 'key1:' in '{first_text}'"
2137
2138    # Fixed behavior: inline comment about key3 now correctly associates with key3
2139    second_structure = utils.find_yaml_associated_structure(comments[1])
2140    assert second_structure, "No structure found for second comment"
2141    second_text = second_structure.text.decode("utf-8")
2142    assert "key3:" in second_text, f"Expected 'key3:' in '{second_text}'"
2143
2144
2145@pytest.mark.parametrize(
2146    ("code", "expected_associations"),
2147    [
2148        # Basic inline comment case
2149        (
2150            b"""key1: value1  # comment about key1
2151key2: value2
2152            """,
2153            ["key1:"],  # Now correctly associates with key1
2154        ),
2155        # Multiple inline comments
2156        (
2157            b"""database:
2158  host: localhost  # production server
2159  port: 5432       # default postgres port
2160  user: admin
2161            """,
2162            [
2163                "host: localhost",
2164                "port: 5432",
2165            ],  # Now correctly associates with the right structures
2166        ),
2167    ],
2168)
2169def test_yaml_inline_comments_fixed_behavior(
2170    code, expected_associations, init_yaml_tree_sitter
2171):
2172    """Test that inline comments now correctly associate with the structure they comment on."""
2173    parser, query = init_yaml_tree_sitter
2174    comments = utils.extract_comments(code, parser, query)
2175    comments.sort(key=lambda x: x.start_point.row)
2176
2177    assert len(comments) == len(expected_associations), (
2178        f"Expected {len(expected_associations)} comments, found {len(comments)}"
2179    )
2180
2181    for i, comment in enumerate(comments):
2182        structure = utils.find_yaml_associated_structure(comment)
2183        assert structure, f"No structure found for comment {i}"
2184        structure_text = structure.text.decode("utf-8")
2185        assert expected_associations[i] in structure_text, (
2186            f"Expected '{expected_associations[i]}' in structure text: '{structure_text}'"
2187        )
2188
2189
2190@pytest.mark.parametrize(
2191    ("code", "expected_associations"),
2192    [
2193        # Inline comments with list items
2194        (
2195            b"""items:
2196  - name: item1  # first item
2197  - name: item2  # second item
2198            """,
2199            [
2200                "name: item1",
2201                "name: item2",
2202            ],  # The inline comment finds the key-value pair within the list item
2203        ),
2204        # Mixed inline and block comments
2205        (
2206            b"""# Block comment for database
2207database:
2208  host: localhost  # inline comment for host
2209  port: 5432
2210  # Block comment for user
2211  user: admin
2212            """,
2213            ["database:", "host: localhost", "user: admin"],
2214        ),
2215        # Inline comments in nested structures
2216        (
2217            b"""services:
2218  web:
2219    image: nginx  # web server image
2220    ports:
2221      - "80:80"   # http port
2222            """,
2223            ["image: nginx", '- "80:80"'],
2224        ),
2225    ],
2226)
2227def test_yaml_inline_comments_comprehensive(
2228    code, expected_associations, init_yaml_tree_sitter
2229):
2230    """Comprehensive test for inline comment behavior in various YAML structures."""
2231    parser, query = init_yaml_tree_sitter
2232    comments = utils.extract_comments(code, parser, query)
2233    comments.sort(key=lambda x: x.start_point.row)
2234
2235    assert len(comments) == len(expected_associations), (
2236        f"Expected {len(expected_associations)} comments, found {len(comments)}"
2237    )
2238
2239    for i, comment in enumerate(comments):
2240        structure = utils.find_yaml_associated_structure(comment)
2241        assert structure, (
2242            f"No structure found for comment {i}: '{comment.text.decode('utf-8')}'"
2243        )
2244        structure_text = structure.text.decode("utf-8")
2245        assert expected_associations[i] in structure_text, (
2246            f"Comment {i} '{comment.text.decode('utf-8')}' -> Expected '{expected_associations[i]}' in '{structure_text}'"
2247        )
2248
2249
2250@pytest.mark.parametrize(
2251    ("text", "expected"),
2252    [
2253        pytest.param("a@file.com", False, id="address"),
2254        pytest.param("x@module", False, id="no-left-boundary"),
2255        pytest.param("* @file", True, id="after-star"),
2256        pytest.param("\\file widget.h", True, id="backslash-at-start"),
2257        pytest.param("@filename", False, id="longer-word"),
2258        pytest.param("@file_x", False, id="underscore"),
2259        pytest.param("/// @fileoverview Widgets.", True, id="fileoverview"),
2260        pytest.param("@fileÍ… x", True, id="non-ascii-mark-ends-the-word"),
2261    ],
2262)
2263def test_documents_file(text: str, expected: bool) -> None:
2264    """A file tag counts at the start of the text or after whitespace, ``*``, ``/`` or
2265    ``!``, and only as a whole ASCII word: an address or a longer word never counts,
2266    and a non-ASCII mark ends the word rather than extending it."""
2267    assert utils.documents_file(text) is expected