1import configparser
   2import re
   3from enum import Enum
   4from pathlib import Path
   5from urllib.request import pathname2url
   6
   7from giturlparse import parse
   8from tree_sitter import Language, Parser, Query, QueryCursor, Tree
   9from tree_sitter import Node as TreeSitterNode
  10
  11from sphinx_codelinks.analyse import cpp_macros
  12
  13# The entity tables live with the naming in ``entity``; they stay importable from
  14# here, where they were.
  15from sphinx_codelinks.analyse.entity import (
  16    COMMENT_TYPES,
  17    ENTITY_HEAD_TYPES,
  18    FIRST_ENTITY_WRAPPER_TYPES,
  19    FIRST_ITEM_WRAPPER_TYPES,
  20    Association,
  21    CppNames,
  22    Mechanism,
  23    in_function_body,
  24    is_doc_comment,
  25    is_entity,
  26    is_trivia,
  27    is_wrapper,
  28    unwrap_entity,
  29)
  30from sphinx_codelinks.analyse.entity import (
  31    ENTITY_NODE_TYPES as ENTITY_NODE_TYPES,
  32)
  33from sphinx_codelinks.analyse.entity import (
  34    SCOPE_NODE_TYPES as SCOPE_NODE_TYPES,
  35)
  36from sphinx_codelinks.analyse.entity import (
  37    SCOPE_PREDICATES as SCOPE_PREDICATES,
  38)
  39from sphinx_codelinks.analyse.entity import (
  40    TRIVIA_TYPES as TRIVIA_TYPES,
  41)
  42from sphinx_codelinks.analyse.entity import (
  43    WRAPPER_TYPES as WRAPPER_TYPES,
  44)
  45from sphinx_codelinks.config import CommentCategory
  46from sphinx_codelinks.logger import get_logger
  47from sphinx_codelinks.source_discover.config import CommentType
  48
  49logger = get_logger(__name__)
  50
  51GIT_HOST_URL_TEMPLATE = {
  52    "github": "https://github.com/{owner}/{repo}/blob/{rev}/{path}#L{lineno}",
  53    "gitlab": "https://gitlab.com/{owner}/{repo}/-/blob/{rev}/{path}#L{lineno}",
  54}
  55
  56PYTHON_QUERY = """
  57                ; Match comments
  58                (comment) @comment
  59
  60                ; Match docstrings inside modules, functions, or classes
  61                (module (expression_statement (string)) @comment)
  62                (function_definition (block (expression_statement (string)) @comment))
  63                (class_definition (block (expression_statement (string)) @comment))
  64            """
  65# The query of every grammar whose comments are one ``comment`` node kind.
  66SIMPLE_COMMENT_QUERY = """(comment) @comment"""
  67# ``html_comment`` is a separate node kind the TypeScript/TSX grammars emit
  68# for ``<!--`` comments: legacy one-line comments, valid in classic scripts only.
  69# Without matching it, markers written in that style are silently dropped.
  70TYPE_SCRIPT_QUERY = """
  71    (comment) @comment
  72    (html_comment) @comment
  73"""
  74RUST_QUERY = """
  75    (line_comment) @comment
  76    (block_comment) @comment
  77"""
[docs]  78# @PowerShell comment nodes open with # or <#, IMPL_PS_16, impl, [FE_POWERSHELL]
  79# PowerShell's grammar reads some ``#`` text inside an expandable (double-quoted)
  80# string as a comment node within the string: a ``#`` line of a here-string, unless
  81# it is the string's first line, and, in an ordinary ``"…"`` string, a ``#`` that
  82# follows an interpolation past whitespace alone. It is string content. The strings
  83# are captured as ``string`` and the subexpressions ``$( … )`` as ``string_code``, so
  84# that :func:`captured_comments` leaves out a comment inside a string unless it is in
  85# a subexpression of it, which is code. Where such a comment takes the string's
  86# closing quote in, it lands in an ``ERROR`` node and in no string: it starts with the
  87# whitespace before its ``#``, so only a node that starts with ``#`` or ``<#``, as
  88# every PowerShell comment does, is captured.
  89POWERSHELL_QUERY = """
  90    ((comment) @comment (#match? @comment "^<?#"))
  91    (expandable_string_literal) @string
  92    (expandable_here_string_literal) @string
  93    (sub_expression) @string_code
  94"""
  95
  96# JSON value node types that can be associated with a comment.
  97JSON_STRUCTURE_TYPES = {
  98    "pair",
  99    "object",
 100    "array",
 101    "string",
 102    "number",
 103    "true",
 104    "false",
 105    "null",
 106}
 107
 108# TypeScript's own module variants. Legacy angle-bracket type assertions
 109# (``<T>x``) are valid syntax only here, not under the TSX grammar (see the
 110# CommentType.ts branch of init_tree_sitter for what goes wrong otherwise), so
 111# these three suffixes get the plain TypeScript grammar and everything else in
 112# the JS/TS family falls back to TSX.
 113TS_STRICT_GRAMMAR_SUFFIXES = {".ts", ".mts", ".cts"}
 114
 115
 116def ts_grammar_key(src_path: Path) -> str:
 117    """Return which tree-sitter-typescript grammar ``src_path`` needs.
 118
 119    ``"typescript"`` for TypeScript's own module variants (``.ts``, ``.mts``,
 120    ``.cts``); ``"tsx"`` for the rest of the JavaScript/TypeScript family
 121    (``.tsx``, ``.jsx``, ``.js``, ``.mjs``, ``.cjs``). Used both to pick the
 122    grammar in ``init_tree_sitter`` and, by callers that parse many files, to
 123    cache one parser per grammar instead of rebuilding one per file.
 124    """
 125    return (
 126        "typescript" if src_path.suffix.lower() in TS_STRICT_GRAMMAR_SUFFIXES else "tsx"
 127    )
 128
 129
 130def is_text_file(filepath: Path, sample_size: int = 2048) -> bool:
 131    """Return True if file is likely text, False if binary."""
 132    try:
 133        with filepath.open("rb") as f:
 134            chunk = f.read(sample_size)
 135        # Quick binary heuristic: null byte present
 136        if b"\x00" in chunk:
 137            return False
 138        # Try UTF-8 decode on the sample
 139        chunk.decode("utf-8")
 140        return True
 141    except UnicodeDecodeError:
 142        return False
 143
 144
[docs] 145# @Tree-sitter parser initialization for multiple languages, IMPL_LANG_1, impl, [FE_C_SUPPORT, FE_CPP, FE_PY, FE_YAML, FE_RUST, FE_GO, FE_JSONC, FE_BASH, FE_TS, FE_POWERSHELL, FE_VHDL]
 146def init_tree_sitter(
 147    comment_type: CommentType, src_path: Path | None = None
 148) -> tuple[Parser, Query]:
 149    """Build the (parser, query) pair for ``comment_type``.
 150
 151    ``src_path`` only matters for ``CommentType.ts``, whose grammar varies by
 152    file suffix (see ``ts_grammar_key``); every other comment type ignores it
 153    and uses a single grammar. When ``src_path`` is omitted the TSX grammar is
 154    assumed, which is the safe default for the whole JS/TS family except
 155    TypeScript's own module variants.
 156    """
 157    if comment_type == CommentType.cpp:
 158        import tree_sitter_cpp
 159
 160        parsed_language = Language(tree_sitter_cpp.language())
 161        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
 162    elif comment_type == CommentType.python:
 163        import tree_sitter_python
 164
 165        parsed_language = Language(tree_sitter_python.language())
 166        query = Query(parsed_language, PYTHON_QUERY)
 167    elif comment_type == CommentType.cs:
 168        import tree_sitter_c_sharp
 169
 170        parsed_language = Language(tree_sitter_c_sharp.language())
 171        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
 172    elif comment_type == CommentType.ts:
[docs] 173        # @TypeScript comment query for tree-sitter, IMPL_TS_3, impl, [FE_TS]
 174        import tree_sitter_typescript
 175
 176        # Legacy angle-bracket type assertions (``<T>x``) are valid TypeScript
 177        # syntax in .ts/.mts/.cts, but the same text is JSX syntax under the TSX
 178        # grammar: ``<string>x`` parses as a jsx_opening_element and swallows the
 179        # rest of the file into a single jsx_text node, silently dropping every
 180        # marker after it. So the plain TypeScript grammar is required for those
 181        # three suffixes. The TSX grammar remains the fallback for the rest of
 182        # the JS/TS family (.tsx, .jsx, .js, .mjs, .cjs): JavaScript has no such
 183        # cast syntax, so TSX is safe there, and it additionally handles JSX
 184        # embedded in plain .js.
 185        if src_path is not None and ts_grammar_key(src_path) == "typescript":
 186            parsed_language = Language(tree_sitter_typescript.language_typescript())
 187        else:
 188            parsed_language = Language(tree_sitter_typescript.language_tsx())
 189        query = Query(parsed_language, TYPE_SCRIPT_QUERY)
 190    elif comment_type == CommentType.yaml:
 191        import tree_sitter_yaml
 192
 193        parsed_language = Language(tree_sitter_yaml.language())
 194        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
 195    elif comment_type == CommentType.rust:
 196        import tree_sitter_rust
 197
 198        parsed_language = Language(tree_sitter_rust.language())
 199        query = Query(parsed_language, RUST_QUERY)
 200    elif comment_type == CommentType.go:
[docs] 201        # @Go comment query for tree-sitter, IMPL_GO_3, impl, [FE_GO]
 202        import tree_sitter_go
 203
 204        parsed_language = Language(tree_sitter_go.language())
 205        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
 206    elif comment_type == CommentType.jsonc:
 207        import tree_sitter_json
 208
 209        parsed_language = Language(tree_sitter_json.language())
 210        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
 211    elif comment_type == CommentType.bash:
[docs] 212        # @Bash comment query for tree-sitter, IMPL_BASH_3, impl, [FE_BASH]
 213        import tree_sitter_bash
 214
 215        parsed_language = Language(tree_sitter_bash.language())
 216        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
 217    elif comment_type == CommentType.powershell:
[docs] 218        # @PowerShell comment query for tree-sitter, IMPL_PS_3, impl, [FE_POWERSHELL]
 219        # ``#`` and ``<# #>`` comments are both ``comment`` nodes in this grammar.
 220        import tree_sitter_powershell
 221
 222        parsed_language = Language(tree_sitter_powershell.language())
 223        query = Query(parsed_language, POWERSHELL_QUERY)
 224    elif comment_type == CommentType.vhdl:
[docs] 225        # @VHDL comment query for tree-sitter, IMPL_VHDL_3, impl, [FE_VHDL]
 226        import tree_sitter_vhdl
 227
 228        parsed_language = Language(tree_sitter_vhdl.language())
 229        # VHDL names its ``--`` and ``/* */`` comments as Rust does
 230        query = Query(parsed_language, RUST_QUERY)
 231    else:
 232        raise ValueError(f"Unsupported comment style: {comment_type}")
 233    parser = Parser(parsed_language)
 234    return parser, query
 235
 236
 237def parse_source(
 238    src_string: bytes, parser: Parser, comment_type: CommentType, src_path: Path
 239) -> tuple[Tree, CppNames | None]:
 240    """The one parse of a source file the analysis reads, and, for a C++ file, what the
 241    whole file decides about the names of its entities (:class:`CppNames`).
 242
 243    Every analysis of a file's markers parses it here, so that every C++ name is made
 244    the one way. A C++ file is parsed from a copy with the namespace macros it defines
 245    itself rewritten (:mod:`sphinx_codelinks.analyse.cpp_macros`): the tree's offsets
 246    are the file's, and every comment, name, tagged scope and excerpt is read from
 247    ``src_string``, through :class:`CppNames` and ``SourceFile.node_text``, never from
 248    the copy. A ``.c`` file is C: it is parsed as written, and gets no file-level names.
 249
 250    The whole buffer is parsed in one piece: read one byte at a time, a multi-byte
 251    UTF-8 character inside a token (a YAML scalar, an identifier, a Rust, Go or C#
 252    character literal) is lexed as invalid, which loses every YAML comment after it
 253    and the scope a marker binds to in bash, YAML or Go. Do not reintroduce a read
 254    callback: a chunk boundary inside a character breaks the parse the same way (a
 255    from-offset chunk of four bytes or more happens to work; a block-aligned one
 256    does not).
 257    """
 258    if comment_type != CommentType.cpp or not cpp_macros.applies(src_path):
 259        return parser.parse(src_string), None
 260    tree, macros = cpp_macros.parse(parser, src_string)
 261    return tree, CppNames(src_string, tree.root_node, macros)
 262
 263
 264def captured_comments(tree: Tree, query: Query) -> list[TreeSitterNode] | None:
 265    """The comment nodes ``query`` captures in ``tree``.
 266
 267    A query may also capture ``string`` nodes, in which a grammar places comment
 268    nodes that are string content (PowerShell's double-quoted strings), and
 269    ``string_code`` nodes, the code such a string holds (PowerShell's ``$( … )``). A
 270    comment is left out when, of those, the nearest one around it is a ``string``.
 271    """
 272    query_cursor = QueryCursor(query)
 273    captures: dict[str, list[TreeSitterNode]] = query_cursor.captures(tree.root_node)
 274    comments = captures.get("comment")
 275    strings = {node.id for node in captures.get("string", ())}
 276    if comments and strings:
 277        code = {node.id for node in captures.get("string_code", ())}
 278        comments = [
 279            comment for comment in comments if not _in_string(comment, strings, code)
 280        ]
 281    return comments
 282
 283
[docs] 284# @PowerShell comments inside strings are string content, IMPL_PS_7, impl, [FE_POWERSHELL]
 285def _in_string(node: TreeSitterNode, strings: set[int], code: set[int]) -> bool:
 286    """Whether the nearest of ``strings`` and ``code`` (node ids) around ``node`` is a
 287    string."""
 288    ancestor = node.parent
 289    while ancestor is not None:
 290        if ancestor.id in code:
 291            return False
 292        if ancestor.id in strings:
 293            return True
 294        ancestor = ancestor.parent
 295    return False
 296
 297
[docs] 298# @Comment extraction from source code using tree-sitter, IMPL_EXTR_1, impl, [FE_DEF]
 299def extract_comments(
 300    src_string: bytes, parser: Parser, query: Query
 301) -> list[TreeSitterNode] | None:
 302    """Get all comments from source files by tree-sitter.
 303
 304    The buffer is parsed whole, as :func:`parse_source` explains; the analysis parses
 305    through :func:`parse_source`, and this is its comment capture alone.
 306    """
 307    return captured_comments(parser.parse(src_string), query)
 308
 309
 310def find_enclosing_scope(
 311    node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
 312) -> TreeSitterNode | None:
 313    """The nearest entity ``node`` is inside of, if any. A wrapper ``node`` is inside of
 314    (a decorated definition, a template) counts as the entity it wraps; a PowerShell
 315    pipeline only when ``node`` is inside that entity, not in another of its commands."""
 316    current = node.parent
 317    while current is not None:
 318        if is_entity(current, comment_type):
 319            return current
 320        if is_wrapper(current, comment_type):
 321            wrapped = unwrap_entity(current, comment_type)
 322            if wrapped is not None and (
 323                current.type not in FIRST_ENTITY_WRAPPER_TYPES.get(comment_type, ())
 324                or _contains(wrapped, node)
 325            ):
 326                return wrapped
 327        current = current.parent
 328    return None
 329
 330
 331def _contains(outer: TreeSitterNode, node: TreeSitterNode) -> bool:
 332    """Whether ``node`` lies within ``outer``: a pipeline's entity is not around a
 333    comment in another command of the pipeline (``Describe 'x' { } | ForEach-Object {
 334    # c }``)."""
 335    return outer.start_byte <= node.start_byte and node.end_byte <= outer.end_byte
 336
 337
 338# The nodes a PowerShell script or script block holds its statements in. A comment
 339# before the first statement of a body, or after the last one on its row, is their
 340# sibling, not the statements'.
 341POWERSHELL_BODY_TYPES = frozenset(
 342    {"statement_list", "script_block", "script_block_body"}
 343)
 344
 345
 346def _first_named_inside(
 347    node: TreeSitterNode, wrappers: frozenset[str]
 348) -> TreeSitterNode:
 349    """``node``'s first named child, followed while the node is one of ``wrappers``, or
 350    ``node`` itself when it is none of them or wraps nothing."""
 351    while node.type in wrappers and node.named_children:
 352        node = node.named_children[0]
 353    return node
 354
 355
 356def _last_named_inside(
 357    node: TreeSitterNode, wrappers: frozenset[str]
 358) -> TreeSitterNode:
 359    """``node``'s last named child, followed while the node is one of ``wrappers``, or
 360    ``node`` itself when it is none of them or wraps nothing.
 361
 362    No comment is skipped: where the trailing path looks into a body, a comment on the
 363    marker's row after the body's last statement is the marker itself, or beside the
 364    body with it, never last in the body (no input in a corpus of 610 files or in
 365    generated shapes reached one).
 366    """
 367    while node.type in wrappers and node.named_children:
 368        node = node.named_children[-1]
 369    return node
 370
 371
 372def find_next_scope(
 373    node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
 374) -> TreeSitterNode | None:
 375    """The entity right after the comment ``node``, if only trivia stands between.
 376
 377    Comments, blank lines and the language's other trivia (attributes, decorators) are
 378    skipped; anything else, a statement or a declaration that is no entity, ends the
 379    search. A comment Python's grammar places between a header (``def f():``) and its
 380    body is followed by the body's first statement; in every other grammar a block
 381    after a comment is the body of the entity the comment is inside. The entity found
 382    is looked through its wrappers (``@decorator``, ``template <…>``, ``export``).
 383
 384    PowerShell's grammar places a comment before a body's first statement beside the
 385    body (its statement list, or the script block holding it), so such a comment is
 386    followed by that first statement too.
 387    """
 388    sibling = node.next_named_sibling
 389    while sibling is not None and is_trivia(sibling, comment_type):
 390        sibling = sibling.next_named_sibling
 391    if (
 392        comment_type == CommentType.python
 393        and sibling is not None
 394        and sibling.type == "block"
 395    ):
 396        sibling = next(
 397            (c for c in sibling.named_children if not is_trivia(c, comment_type)),
 398            None,
 399        )
[docs] 400    # @PowerShell next entity across statement bodies, IMPL_PS_6, impl, [FE_POWERSHELL]
 401    if comment_type == CommentType.powershell and sibling is not None:
 402        sibling = _first_named_inside(sibling, POWERSHELL_BODY_TYPES)
 403    if sibling is not None:
 404        sibling = _first_item(sibling, comment_type)
 405    return unwrap_entity(sibling, comment_type)
 406
 407
[docs] 408# @VHDL next entity through a wrapper's first item, IMPL_VHDL_4, impl, [FE_VHDL]
 409def _first_item(
 410    node: TreeSitterNode, comment_type: CommentType
 411) -> TreeSitterNode | None:
 412    """The first item of ``node`` when it is one of the language's first-item wrappers
 413    (:data:`FIRST_ITEM_WRAPPER_TYPES`), past comments and the child kinds the wrapper
 414    passes over (a VHDL design unit's context clauses); else ``node`` itself."""
 415    passed = FIRST_ITEM_WRAPPER_TYPES.get(comment_type, {}).get(node.type)
 416    if passed is None:
 417        return node
 418    return next(
 419        (
 420            child
 421            for child in node.named_children
 422            if not is_trivia(child, comment_type) and child.type not in passed
 423        ),
 424        None,
 425    )
 426
 427
 428def _code_before_on_row(node: TreeSitterNode) -> bool:
 429    """Whether code precedes the comment ``node`` on its first row.
 430
 431    Walks the previous siblings, anonymous tokens included and comments skipped,
 432    climbing to the parent when there are none. A token that ends at column 0 of the
 433    row (a preprocessor directive's newline) is not on it, and an entity's head (an
 434    attribute, a decorator, a template header) is no code: the item it opens follows.
 435    """
 436    current: TreeSitterNode | None = node
 437    while current is not None:
 438        previous = current.prev_sibling
 439        while previous is not None and previous.type in COMMENT_TYPES:
 440            previous = previous.prev_sibling
 441        if previous is not None:
 442            if previous.type in ENTITY_HEAD_TYPES:
 443                return False
 444            return (
 445                previous.end_point.row == node.start_point.row
 446                and previous.end_point.column > 0
 447            )
 448        current = current.parent
 449    return False
 450
 451
 452def _entity_on_row(
 453    node: TreeSitterNode, comment_type: CommentType
 454) -> TreeSitterNode | None:
 455    """The entity that ends on the trailing comment ``node``'s row, before it.
 456
 457    PowerShell's grammar places a comment after a body's last statement beside the
 458    body, so the entity is looked for at the end of that body.
 459    """
 460    previous = node.prev_named_sibling
 461    while previous is not None and previous.type in COMMENT_TYPES:
 462        previous = previous.prev_named_sibling
 463    if previous is not None and previous.end_point.row == node.start_point.row:
[docs] 464        # @PowerShell entity on a trailing marker's row, IMPL_PS_9, impl, [FE_POWERSHELL]
 465        if comment_type == CommentType.powershell:
 466            previous = _last_named_inside(previous, POWERSHELL_BODY_TYPES)
 467        if comment_type == CommentType.vhdl:
 468            return _innermost_on_row(previous, node.start_point.row, comment_type)
 469        return unwrap_entity(previous, comment_type)
 470    return None
 471
 472
[docs] 473# @VHDL entity on a trailing marker's row, IMPL_VHDL_6, impl, [FE_VHDL]
 474def _innermost_on_row(
 475    node: TreeSitterNode, row: int, comment_type: CommentType
 476) -> TreeSitterNode | None:
 477    """The innermost entity of ``node``'s subtree, ``node`` included, that ends on
 478    ``row``, following the last child that is no comment and ends there.
 479
 480    VHDL's grammar places a comment after a declaration beside the declarative part
 481    that holds it (an architecture's ``signal s : bit; -- c`` is beside the
 482    architecture's head), and a comment after a port beside the port, so the entity
 483    a trailing comment follows is looked for down the subtree that ends on its row.
 484
 485    The search does not enter a subprogram's parameter list: after
 486    ``procedure inc(variable v : inout integer); -- c`` the list ends on the row too,
 487    and the comment follows the subprogram, not its last parameter. A comment inside a
 488    list written over several rows starts its search at the parameter before it.
 489    """
 490    found = None
 491    current: TreeSitterNode | None = node
 492    while current is not None and current.type not in _ROW_SEARCH_STOP_TYPES:
 493        if is_entity(current, comment_type):
 494            found = current
 495        current = next(
 496            (
 497                child
 498                for child in reversed(current.named_children)
 499                if child.type not in COMMENT_TYPES and child.end_point.row == row
 500            ),
 501            None,
 502        )
 503    return found
 504
 505
 506# The nodes :func:`_innermost_on_row` does not enter: a VHDL subprogram's parameter list.
 507_ROW_SEARCH_STOP_TYPES = frozenset({"parameter_list_specification"})
 508
 509
 510def _is_file_header(node: TreeSitterNode) -> bool:
 511    """Whether the comment ``node`` is one of the comments before the file's first item,
 512    and a blank line separates the last of them from that item."""
 513    parent = node.parent
 514    if parent is None or parent.parent is not None:
 515        return False
 516    siblings = parent.children
 517    index = 0
 518    in_block = False
 519    last_comment: TreeSitterNode | None = None
 520    while index < len(siblings) and siblings[index].type in COMMENT_TYPES:
 521        if siblings[index].id == node.id:
 522            in_block = True
 523        last_comment = siblings[index]
 524        index += 1
 525    if not in_block or last_comment is None or index >= len(siblings):
 526        return in_block
 527    return siblings[index].start_point.row - _last_row(last_comment) > 1
 528
 529
 530def _last_row(comment: TreeSitterNode) -> int:
 531    """The last row the ``comment`` node is written on."""
 532    if comment.end_point.column == 0:
 533        # a Rust doc line comment (``///``) takes its line break in its ``doc_comment``
 534        # child, so its node ends at column 0 of the next row
 535        return comment.end_point.row - 1
 536    return comment.end_point.row
 537
 538
 539# The tokens that open the line of a C or C++ preprocessor conditional, as the grammar
 540# names them (``# if`` is ``#if`` too).
 541_CONDITIONAL_DIRECTIVES = frozenset(
 542    {"#if", "#ifdef", "#ifndef", "#elif", "#elifdef", "#elifndef", "#else", "#endif"}
 543)
 544
 545
 546def _directly_above_a_conditional(node: TreeSitterNode) -> bool:
 547    """Whether the line of a preprocessor conditional (``#if`` ... ``#endif``) comes
 548    right after the comment ``node``: past other comments only, and with no blank line
 549    between.
 550
 551    When ``node`` ends a conditional's arm, what comes next is the arm's parent's: the
 552    next ``#elif`` or ``#else``, or the ``#endif``.
 553    """
 554    last_row = _last_row(node)
 555    current = node
 556    while True:
 557        following = current.next_sibling
 558        outer = current
 559        while following is None and outer.parent is not None:
 560            outer = outer.parent
 561            following = outer.next_sibling
 562        if following is None or following.start_point.row - last_row > 1:
 563            return False
 564        if following.type not in COMMENT_TYPES:
 565            break
 566        last_row = _last_row(following)
 567        current = following
 568    while following.child_count:
 569        following = following.children[0]
 570    return following.type in _CONDITIONAL_DIRECTIVES
 571
 572
 573class Unattached(Enum):
 574    """Why :func:`associate` attached a comment to no entity, where the analysis
 575    warns about it; every other comment on nothing is ``None``."""
 576
 577    above_conditional = "above_conditional"
 578    """Rule 7: a C or C++ comment directly above a preprocessor conditional's line,
 579    outside a function's body."""
 580
 581
 582# A Doxygen or JSDoc tag saying a documentation comment documents the file, not the
 583# item after it: at the start of the text or after ASCII whitespace, ``*``, ``/`` or
 584# ``!``, and ending the word, which is ASCII (as Doxygen and JSDoc commands are).
 585_FILE_DOC_TAG = re.compile(
 586    r"(?:^|(?<=[ \t\n\r\f\v*/!]))[@\\]"
 587    r"(?:file|fileoverview|packageDocumentation|module)(?![A-Za-z0-9_])"
 588)
 589
 590
 591def documents_file(text: str) -> bool:
 592    """Whether a documentation comment's ``text`` says it documents the file.
 593
 594    It does when a file tag (Doxygen's file command, JSDoc's fileoverview, module and
 595    packageDocumentation tags, see ``_FILE_DOC_TAG``) appears as a whole word, at the
 596    start or after whitespace or one of ``*/!``; an address such as ``a@file.com`` or a
 597    longer word such as ``@filename`` does not count.
 598    """
 599    return _FILE_DOC_TAG.search(text) is not None
 600
 601
[docs] 602# @A VHDL doc comment is its run of --! lines, IMPL_VHDL_7, impl, [FE_VHDL]
 603def _doc_block_text(
 604    node: TreeSitterNode, comment_type: CommentType, written: bytes
 605) -> str:
 606    """The text of the documentation block the doc comment ``node`` is part of, which
 607    rule 5 reads for a file tag.
 608
 609    A block comment is its own block. VHDL has no block doc comment: Doxygen reads a
 610    run of ``--!`` lines on consecutive rows as one block, so a ``--! @file`` line
 611    makes the whole run document the file, the lines after it included.
 612    """
 613    if comment_type != CommentType.vhdl:
 614        return written.decode("utf-8", "replace")
 615    lines = [written]
 616    current = node
 617    while (previous := current.prev_sibling) is not None and _continues_doc_run(
 618        previous, current, comment_type
 619    ):
 620        lines.insert(0, previous.text or b"")
 621        current = previous
 622    current = node
 623    while (following := current.next_sibling) is not None and _continues_doc_run(
 624        current, following, comment_type
 625    ):
 626        lines.append(following.text or b"")
 627        current = following
 628    return b"\n".join(lines).decode("utf-8", "replace")
 629
 630
 631def _continues_doc_run(
 632    upper: TreeSitterNode, lower: TreeSitterNode, comment_type: CommentType
 633) -> bool:
 634    """Whether the comment ``lower`` continues the doc comment run ``upper`` is in: both
 635    documentation line comments, ``lower`` on the row after ``upper``'s last."""
 636    return (
 637        upper.type in COMMENT_TYPES
 638        and lower.type in COMMENT_TYPES
 639        and lower.start_point.row == _last_row(upper) + 1
 640        and is_doc_comment(upper, comment_type)
 641        and is_doc_comment(lower, comment_type)
 642    )
 643
 644
 645def _in_head_of(
 646    node: TreeSitterNode, entity: TreeSitterNode, comment_type: CommentType
 647) -> bool:
 648    """Whether the comment ``node`` sits in ``entity``'s head: inside, or right after, an
 649    attribute, a decorator or a template header that ``entity`` (or its wrapper) owns."""
 650    parent = node.parent
 651    head = parent if parent is not None and parent.type in ENTITY_HEAD_TYPES else None
 652    if head is None:
 653        previous = node.prev_sibling
 654        while previous is not None and previous.type in COMMENT_TYPES:
 655            previous = previous.prev_sibling
 656        if previous is not None and previous.type in ENTITY_HEAD_TYPES:
 657            head = previous
 658    owner = head.parent if head is not None else None
 659    if owner is None:
 660        return False
 661    if owner.id == entity.id:
 662        return True
 663    if not is_wrapper(owner, comment_type):
 664        return False
 665    wrapped = unwrap_entity(owner, comment_type)
 666    return wrapped is not None and wrapped.id == entity.id
 667
 668
 669def _is_inner_doc(text: bytes, comment_type: CommentType) -> bool:
 670    """Whether the comment ``text`` is a Rust inner doc comment (``//!``, ``/*!``): it
 671    documents what it is inside."""
 672    if comment_type != CommentType.rust:
 673        return False
 674    return text.startswith((b"//!", b"/*!"))
 675
 676
 677# PowerShell's comment-based-help keyword lines: a line of a comment that holds a
 678# help keyword alone, or a keyword that takes an argument and that one argument, with
 679# whitespace around, in any letter case, makes the comment help.
 680_POWERSHELL_HELP_KEYWORD = re.compile(
 681    r"^[ \t]*\.(?:"
 682    r"(?:SYNOPSIS|DESCRIPTION|EXAMPLE|INPUTS|OUTPUTS|NOTES|LINK|COMPONENT|ROLE"
 683    r"|FUNCTIONALITY)"
 684    r"|(?:PARAMETER|FORWARDHELPTARGETNAME|FORWARDHELPCATEGORY|REMOTEHELPRUNSPACE"
 685    r"|EXTERNALHELP)(?:[ \t]+[^ \t\r\n]+)?"
 686    r")[ \t\r]*$",
 687    re.IGNORECASE | re.MULTILINE,
 688)
 689
 690
 691def _powershell_hash_run(node: TreeSitterNode) -> list[TreeSitterNode]:
 692    """The ``#`` comments on consecutive rows that the ``#`` comment ``node`` is one
 693    of, in order."""
 694    run = [node]
 695    previous = node.prev_named_sibling
 696    while (
 697        previous is not None
 698        and previous.type in COMMENT_TYPES
 699        and not (previous.text or b"").startswith(b"<#")
 700        and previous.end_point.row == run[0].start_point.row - 1
 701    ):
 702        run.insert(0, previous)
 703        previous = previous.prev_named_sibling
 704    following = node.next_named_sibling
 705    while (
 706        following is not None
 707        and following.type in COMMENT_TYPES
 708        and not (following.text or b"").startswith(b"<#")
 709        and following.start_point.row == run[-1].end_point.row + 1
 710    ):
 711        run.append(following)
 712        following = following.next_named_sibling
 713    return run
 714
 715
[docs] 716# @PowerShell comment-based help documents its function, IMPL_PS_14, impl, [FE_POWERSHELL]
 717def powershell_help_function(
 718    node: TreeSitterNode, text: bytes | None = None
 719) -> TreeSitterNode | None:
 720    """The function whose comment-based help the comment ``node`` is, first in its body.
 721
 722    Help is a ``<# #>`` comment, or a run of ``#`` comments on consecutive rows,
 723    holding a comment-based-help keyword line: a line that holds a keyword
 724    (``.SYNOPSIS``, ``.DESCRIPTION``, ``.EXAMPLE``, ...) alone, or one that takes an
 725    argument with that argument (``.PARAMETER Path``), in any letter case. First in
 726    the body is before every statement of the function's body (its ``param`` block
 727    included), where the grammar places it as the function's child.
 728
 729    :param text: The comment's text as the file writes it; ``node.text`` when not given.
 730    """
 731    parent = node.parent
 732    if parent is None or parent.type != "function_statement":
 733        return None
 734    body = next((c for c in parent.named_children if c.type == "script_block"), None)
 735    if body is not None and node.start_byte > body.start_byte:
 736        return None
 737    written = (node.text or b"") if text is None else text
 738    if written.startswith(b"<#"):
 739        help_text = written[2:-2] if written.endswith(b"#>") else written[2:]
 740    else:
 741        help_text = b"\n".join(
 742            (comment.text or b"")[1:] for comment in _powershell_hash_run(node)
 743        )
 744    if _POWERSHELL_HELP_KEYWORD.search(help_text.decode("utf-8", "replace")) is None:
 745        return None
 746    return parent
 747
 748
 749def _find_yaml_structure_in_block_node(
 750    block_node: TreeSitterNode,
 751) -> TreeSitterNode | None:
 752    """Find YAML structure elements within a block_node."""
 753    for grandchild in block_node.named_children:
 754        if grandchild.type == "block_mapping":
 755            for ggchild in grandchild.named_children:
 756                if ggchild.type == "block_mapping_pair":
 757                    return ggchild
 758        elif grandchild.type == "block_sequence":
 759            for ggchild in grandchild.named_children:
 760                if ggchild.type == "block_sequence_item":
 761                    return ggchild
 762    return None
 763
 764
 765def find_yaml_next_structure(node: TreeSitterNode) -> TreeSitterNode | None:
 766    """Find the next YAML structure element after the comment node."""
 767    current = node.next_named_sibling
 768    while current:
 769        if current.type in {
 770            "block_mapping_pair",
 771            "block_sequence_item",
 772            "flow_mapping",
 773            "flow_sequence",
 774        }:
 775            return current
 776        if current.type == "document":
 777            for child in current.named_children:
 778                if child.type == "block_node":
 779                    result = _find_yaml_structure_in_block_node(child)
 780                    if result:
 781                        return result
 782        if current.type == "block_node":
 783            result = _find_yaml_structure_in_block_node(current)
 784            if result:
 785                return result
 786        current = current.next_named_sibling
 787    return None
 788
 789
 790def find_prev_sibling_on_same_row(node: TreeSitterNode) -> TreeSitterNode | None:
 791    """Find a previous named sibling that is on the same row as the comment.
 792
 793    Grammar-agnostic: used to detect inline comments in both YAML and JSONC.
 794    """
 795    comment_row = node.start_point.row
 796    current = node.prev_named_sibling
 797
 798    while current:
 799        # Check if this sibling ends on the same row as the comment starts
 800        # This indicates it's an inline comment
 801        if current.end_point.row == comment_row:
 802            return current
 803        # If we find a sibling that ends before the comment row, we can stop
 804        # as we won't find any siblings on the same row going backwards
 805        if current.end_point.row < comment_row:
 806            break
 807        current = current.prev_named_sibling
 808
 809    return None
 810
 811
 812def _yaml_associate(node: TreeSitterNode) -> Association | None:
 813    """The YAML structure (key-value pair, list item, etc.) a comment is attached to."""
 814    # First, check if this is an inline comment by looking for a previous sibling on the same row
 815    prev_sibling_same_row = find_prev_sibling_on_same_row(node)
 816    if prev_sibling_same_row:
 817        return Association(prev_sibling_same_row, "trailing")
 818
 819    # If no previous sibling on same row, try to find the next named sibling (structure after the comment)
 820    structure = find_yaml_next_structure(node)
 821    if structure:
 822        return Association(structure, "leading")
 823
 824    # If no next sibling found, traverse up to find parent structure
 825    parent = node.parent
 826    while parent:
 827        if parent.type in {"block_mapping_pair", "block_sequence_item"}:
 828            return Association(parent, "enclosing")
 829        parent = parent.parent
 830
 831    return None
 832
 833
 834def find_yaml_associated_structure(node: TreeSitterNode) -> TreeSitterNode | None:
 835    """Find the YAML structure (key-value pair, list item, etc.) associated with a comment."""
 836    association = _yaml_associate(node)
 837    return association.node if association is not None else None
 838
 839
[docs] 840# @JSONC comment-to-structure association, IMPL_JSONC_2, impl, [FE_JSONC]
 841def _jsonc_associate(node: TreeSitterNode) -> Association | None:
 842    """Find the JSON structure (key/value pair, value, list item) for a comment.
 843
 844    JSON is data rather than code, so association follows the same intent as YAML:
 845    an inline comment belongs to the value on its row, a leading comment belongs to
 846    the following structure, otherwise it belongs to the enclosing structure.
 847    """
 848    # Inline comment: a value/pair on the same row, before the comment
 849    prev_sibling_same_row = find_prev_sibling_on_same_row(node)
 850    if prev_sibling_same_row:
 851        return Association(prev_sibling_same_row, "trailing")
 852
 853    # Leading comment: the next structure following the comment
 854    current = node.next_named_sibling
 855    while current:
 856        if current.type in JSON_STRUCTURE_TYPES:
 857            return Association(current, "leading")
 858        current = current.next_named_sibling
 859
 860    # Otherwise: the enclosing structure
 861    parent = node.parent
 862    while parent:
 863        if parent.type in {"pair", "object", "array"}:
 864            return Association(parent, "enclosing")
 865        parent = parent.parent
 866
 867    return None
 868
 869
 870def find_jsonc_associated_structure(node: TreeSitterNode) -> TreeSitterNode | None:
 871    """Find the JSON structure (key/value pair, value, list item) for a comment."""
 872    association = _jsonc_associate(node)
 873    return association.node if association is not None else None
 874
 875
[docs] 876# @Attach a marker to the adjacent code entity, IMPL_ENTITY_1, impl, [FE_DEF]
 877def associate(
 878    node: TreeSitterNode,
 879    comment_type: CommentType = CommentType.cpp,
 880    *,
 881    text: bytes | None = None,
 882) -> Association | Unattached | None:
 883    """The entity a comment is attached to, and how (see :data:`Mechanism`).
 884
 885    The rules, in order:
 886
 887    1. YAML and JSONC: the data structure on the comment's row, else the next one,
 888       else the enclosing one.
 889    2. A Python docstring documents the definition it is in.
 890    3. A Rust inner doc comment (``//!``) documents what it is inside, and PowerShell
 891       comment-based help first in a function's body documents that function (see
 892       :func:`powershell_help_function`).
 893    4. A trailing comment (code before it on its row) belongs to the entity on its
 894       row, else to the enclosing one; an attribute, a decorator or a template header
 895       before it is no code.
 896    5. The comments before the file's first item, when a blank line separates the last
 897       of them from it, belong to the file: no entity. A documentation comment is
 898       exempt: it documents the next item, blank lines or not (rustc, Doxygen) --
 899       unless it says it documents the file (``@file``, ``@fileoverview``).
 900    6. A leading comment belongs to the entity right after it, when only trivia
 901       stands between (comments, blank lines, attributes, decorators), through its
 902       wrappers; never across other code.
 903    7. A C or C++ comment directly above the line of a preprocessor conditional
 904       (``#if``, ``#ifdef``, ``#ifndef``, ``#elif``, ``#elifdef``, ``#elifndef``,
 905       ``#else``, ``#endif``, as the grammar reads them) -- past other comments,
 906       with no blank line between -- belongs to no entity anywhere outside a
 907       function's body (at file level, in a namespace, class, struct, union or enum
 908       body, or in an ``extern "C"`` block): clang attaches a comment to no
 909       declaration across a ``#`` line. With a blank line between, or in a
 910       function's body (a lambda's, or a class defined in one, included), rule 8
 911       holds.
 912    8. Otherwise, the enclosing entity, if any.
 913
 914    A documentation comment attached by rule 4 or 6 is a ``doc_comment``. By rule 8, a
 915    comment in the entity's head (after an attribute, a decorator or a template header
 916    the entity owns) leads it, and a trailing comment whose entity starts on its row
 917    (``def f():  # c``) is ``trailing``; any other is ``enclosing``.
 918
 919    :param node: The comment node, or a Python docstring's ``expression_statement``.
 920    :param comment_type: The language of the file ``node`` was parsed from.
 921    :param text: The comment's text as the file writes it (``SourceComment.text``),
 922        where a C++ tree is parsed from a prepared copy; ``node.text`` when not given.
 923    :return: The association; :attr:`Unattached.above_conditional` when rule 7
 924        attaches the comment to no entity, and ``None`` when another rule does (file
 925        level).
 926    """
 927    if comment_type == CommentType.yaml:
 928        # YAML uses different structure association logic
 929        return _yaml_associate(node)
 930
 931    if comment_type == CommentType.jsonc:
 932        # JSONC uses data-aware structure association logic
 933        return _jsonc_associate(node)
 934
 935    written = (node.text or b"") if text is None else text
 936    declared: Mechanism = "doc_comment"
 937    if node.type == CommentCategory.docstring or _is_inner_doc(written, comment_type):
 938        scope = find_enclosing_scope(node, comment_type)
 939        return Association(scope, declared) if scope is not None else None
 940    if comment_type == CommentType.powershell:
 941        documented = powershell_help_function(node, written)
 942        if documented is not None:
 943            return Association(documented, declared)
 944    is_doc = is_doc_comment(node, comment_type, text=written)
 945
 946    def enclosing(trailing: bool = False) -> Association | None:
 947        scope = find_enclosing_scope(node, comment_type)
 948        if scope is None:
 949            return None
 950        if _in_head_of(node, scope, comment_type):
 951            return Association(scope, declared if is_doc else "leading")
 952        if trailing and scope.start_point.row == node.start_point.row:
 953            return Association(scope, declared if is_doc else "trailing")
 954        return Association(scope, "enclosing")
 955
 956    if _code_before_on_row(node):
 957        entity = _entity_on_row(node, comment_type)
 958        if entity is None:
 959            return enclosing(trailing=True)
 960        return Association(entity, declared if is_doc else "trailing")
 961    if _is_file_header(node) and (
 962        not is_doc or documents_file(_doc_block_text(node, comment_type, written))
 963    ):
 964        return None
 965    entity = find_next_scope(node, comment_type)
 966    if entity is None:
 967        if (
 968            comment_type == CommentType.cpp
 969            and not in_function_body(node)
 970            and _directly_above_a_conditional(node)
 971        ):
 972            return Unattached.above_conditional
 973        return enclosing()
 974    return Association(entity, declared if is_doc else "leading")
 975
 976
 977def find_associated_scope(
 978    node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
 979) -> TreeSitterNode | None:
 980    """Find the associated scope of a comment: the node :func:`associate` binds."""
 981    association = associate(node, comment_type)
 982    return association.node if isinstance(association, Association) else None
 983
 984
 985def _git_dir(path: Path) -> Path | None:
 986    """The git directory for a checkout rooted at ``path``, or None if it is not one.
 987
 988    In a plain checkout ``.git`` is a directory. In a LINKED WORKTREE it is a file
 989    holding ``gitdir: <path>`` -- git's documented gitfile format -- and the real
 990    directory is elsewhere, under the main repository's ``.git/worktrees/<name>``.
 991    Treating the file as "not a repository" is what made every worktree of this
 992    repository look unversioned (useblocks/sphinx-codelinks#106).
 993    """
 994    dot_git = path / ".git"
 995    if dot_git.is_dir():
 996        return dot_git
 997    if dot_git.is_file():
 998        content = dot_git.read_text(encoding="utf-8", errors="replace").strip()
 999        if content.startswith("gitdir:"):
1000            target = Path(content.split(":", 1)[1].strip())
1001            if not target.is_absolute():
1002                target = (path / target).resolve()
1003            if target.is_dir():
1004                return target
1005    return None
1006
1007
1008def _git_common_dir(git_dir: Path) -> Path:
1009    """The repository-wide git directory that ``git_dir`` belongs to.
1010
1011    A linked worktree's git directory carries a ``commondir`` file naming the main
1012    repository's, and that is where ``config`` and every shared ref live -- only
1013    per-worktree state (``HEAD``, ``refs/bisect``, ``refs/worktree``) is local. A plain
1014    checkout has no ``commondir`` and the two are the same directory.
1015    """
1016    commondir = git_dir / "commondir"
1017    if not commondir.is_file():
1018        return git_dir
1019    target = Path(commondir.read_text(encoding="utf-8").strip())
1020    if not target.is_absolute():
1021        target = (git_dir / target).resolve()
1022    return target
1023
1024
1025def find_git_root(src_dir: Path) -> Path | None:
1026    """The git root :func:`locate_git_root` finds, without its warning when none."""
1027    current = src_dir.resolve()
1028    parents = list(current.parents)
1029    parents.append(current)
1030    for parent in parents:
1031        if _git_dir(parent) is not None:
1032            return parent
1033    return None
1034
1035
1036def locate_git_root(src_dir: Path) -> Path | None:
1037    """Traverse upwards to find git root."""
1038    found = find_git_root(src_dir)
1039    if found is not None:
1040        return found
1041    logger.warning(
1042        f"git root is not found in the parent of {src_dir}",
1043        subtype="git_root",
1044        location=str(src_dir),
1045    )
1046    return None
1047
1048
1049def get_remote_url(git_root: Path, remote_name: str = "origin") -> str | None:
1050    """Get remote url from the git directory's config."""
1051    git_dir = _git_dir(git_root)
1052    # a worktree's remotes are the main repository's, so `config` is in the common dir
1053    config_path = (
1054        _git_common_dir(git_dir) if git_dir is not None else git_root / ".git"
1055    ) / "config"
1056    if not config_path.exists():
1057        logger.warning(
1058            f"{config_path} does not exist",
1059            subtype="git_config",
1060            location=str(config_path),
1061        )
1062        return None
1063
1064    config = configparser.ConfigParser(allow_no_value=True, strict=False)
1065    config.read(config_path)
1066    section = f'remote "{remote_name}"'
1067    if section in config and "url" in config[section]:
1068        url: str = config[section]["url"]
1069        return url
1070    logger.warning(
1071        f"remote-url is not found in {config_path}",
1072        subtype="git_remote",
1073        location=str(config_path),
1074    )
1075    return None
1076
1077
1078def _packed_ref(common_dir: Path, ref: str) -> str | None:
1079    """The sha ``packed-refs`` records for ``ref``, or None.
1080
1081    A ref has no file of its own once ``git gc`` (or ``git pack-refs``) has run, which
1082    happens unattended on any long-lived checkout -- so a loose-file lookup alone reports
1083    "no rev" for a perfectly ordinary repository. The file is one ``<sha> <refname>`` per
1084    line; ``^<sha>`` lines are the peeled targets of annotated tags and are skipped, as
1085    is the leading ``#`` header.
1086    """
1087    packed = common_dir / "packed-refs"
1088    if not packed.is_file():
1089        return None
1090    for line in packed.read_text(encoding="utf-8", errors="replace").splitlines():
1091        if not line or line.startswith(("#", "^")):
1092            continue
1093        sha, _, name = line.partition(" ")
1094        if name.strip() == ref:
1095            return sha
1096    return None
1097
1098
1099def get_current_rev(git_root: Path) -> str | None:
1100    """Get current commit rev from the git directory's HEAD."""
1101    # HEAD is per-worktree, so it is read from the worktree's OWN git directory
1102    git_dir = _git_dir(git_root) or git_root / ".git"
1103    head_path = git_dir / "HEAD"
1104    if not head_path.exists():
1105        logger.warning(
1106            f"{head_path} does not exist",
1107            subtype="git_head",
1108            location=str(head_path),
1109        )
1110        return None
1111    head_content = head_path.read_text().strip()
1112    if not head_content.startswith("ref: "):
1113        # Detached HEAD (e.g. CI checkouts): .git/HEAD holds the commit SHA
1114        # directly, which is exactly the rev we want.
1115        return head_content
1116
1117    ref = head_content.split(":", 1)[1].strip()
1118    common_dir = _git_common_dir(git_dir)
1119    ref_path = git_dir / ref
1120    if not ref_path.exists():
1121        # every branch is a SHARED ref, so in a worktree it lives in the common
1122        # directory; only refs/bisect and refs/worktree are per-worktree
1123        ref_path = common_dir / ref
1124    if ref_path.exists():
1125        return ref_path.read_text().strip()
1126    packed = _packed_ref(common_dir, ref)
1127    if packed is not None:
1128        return packed
1129    logger.warning(
1130        f"{ref_path} does not exist",
1131        subtype="git_ref",
1132        location=str(ref_path),
1133    )
1134    return None
1135
1136
1137def form_https_url(
1138    git_url: str, rev: str, project_path: Path, filepath: Path, lineno: int
1139) -> str | None:
1140    parsed_url = parse(git_url)
1141    template = GIT_HOST_URL_TEMPLATE.get(parsed_url.platform)
1142    if not template:
1143        logger.warning(
1144            f"Unsupported Git host: {parsed_url.platform}",
1145            subtype="git_host",
1146        )
1147        return git_url
1148    https_url = template.format(
1149        owner=parsed_url.owner,
1150        repo=parsed_url.repo,
1151        rev=rev,
1152        path=pathname2url(str(filepath.absolute().relative_to(project_path))),
1153        lineno=str(lineno),
1154    )
1155    return https_url