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    Association,
 19    CppNames,
 20    Mechanism,
 21    in_function_body,
 22    is_doc_comment,
 23    is_entity,
 24    is_trivia,
 25    is_wrapper,
 26    unwrap_entity,
 27)
 28from sphinx_codelinks.analyse.entity import (
 29    ENTITY_NODE_TYPES as ENTITY_NODE_TYPES,
 30)
 31from sphinx_codelinks.analyse.entity import (
 32    SCOPE_NODE_TYPES as SCOPE_NODE_TYPES,
 33)
 34from sphinx_codelinks.analyse.entity import (
 35    SCOPE_PREDICATES as SCOPE_PREDICATES,
 36)
 37from sphinx_codelinks.analyse.entity import (
 38    TRIVIA_TYPES as TRIVIA_TYPES,
 39)
 40from sphinx_codelinks.analyse.entity import (
 41    WRAPPER_TYPES as WRAPPER_TYPES,
 42)
 43from sphinx_codelinks.config import CommentCategory
 44from sphinx_codelinks.logger import get_logger
 45from sphinx_codelinks.source_discover.config import CommentType
 46
 47logger = get_logger(__name__)
 48
 49GIT_HOST_URL_TEMPLATE = {
 50    "github": "https://github.com/{owner}/{repo}/blob/{rev}/{path}#L{lineno}",
 51    "gitlab": "https://gitlab.com/{owner}/{repo}/-/blob/{rev}/{path}#L{lineno}",
 52}
 53
 54PYTHON_QUERY = """
 55                ; Match comments
 56                (comment) @comment
 57
 58                ; Match docstrings inside modules, functions, or classes
 59                (module (expression_statement (string)) @comment)
 60                (function_definition (block (expression_statement (string)) @comment))
 61                (class_definition (block (expression_statement (string)) @comment))
 62            """
 63# The query of every grammar whose comments are one ``comment`` node kind.
 64SIMPLE_COMMENT_QUERY = """(comment) @comment"""
 65# ``html_comment`` is a separate node kind the TypeScript/TSX grammars emit
 66# for ``<!--`` comments: legacy one-line comments, valid in classic scripts only.
 67# Without matching it, markers written in that style are silently dropped.
 68TYPE_SCRIPT_QUERY = """
 69    (comment) @comment
 70    (html_comment) @comment
 71"""
 72RUST_QUERY = """
 73    (line_comment) @comment
 74    (block_comment) @comment
 75"""
 76
 77# JSON value node types that can be associated with a comment.
 78JSON_STRUCTURE_TYPES = {
 79    "pair",
 80    "object",
 81    "array",
 82    "string",
 83    "number",
 84    "true",
 85    "false",
 86    "null",
 87}
 88
 89# TypeScript's own module variants. Legacy angle-bracket type assertions
 90# (``<T>x``) are valid syntax only here, not under the TSX grammar (see the
 91# CommentType.ts branch of init_tree_sitter for what goes wrong otherwise), so
 92# these three suffixes get the plain TypeScript grammar and everything else in
 93# the JS/TS family falls back to TSX.
 94TS_STRICT_GRAMMAR_SUFFIXES = {".ts", ".mts", ".cts"}
 95
 96
 97def ts_grammar_key(src_path: Path) -> str:
 98    """Return which tree-sitter-typescript grammar ``src_path`` needs.
 99
100    ``"typescript"`` for TypeScript's own module variants (``.ts``, ``.mts``,
101    ``.cts``); ``"tsx"`` for the rest of the JavaScript/TypeScript family
102    (``.tsx``, ``.jsx``, ``.js``, ``.mjs``, ``.cjs``). Used both to pick the
103    grammar in ``init_tree_sitter`` and, by callers that parse many files, to
104    cache one parser per grammar instead of rebuilding one per file.
105    """
106    return (
107        "typescript" if src_path.suffix.lower() in TS_STRICT_GRAMMAR_SUFFIXES else "tsx"
108    )
109
110
111def is_text_file(filepath: Path, sample_size: int = 2048) -> bool:
112    """Return True if file is likely text, False if binary."""
113    try:
114        with filepath.open("rb") as f:
115            chunk = f.read(sample_size)
116        # Quick binary heuristic: null byte present
117        if b"\x00" in chunk:
118            return False
119        # Try UTF-8 decode on the sample
120        chunk.decode("utf-8")
121        return True
122    except UnicodeDecodeError:
123        return False
124
125
[docs]126# @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]
127def init_tree_sitter(
128    comment_type: CommentType, src_path: Path | None = None
129) -> tuple[Parser, Query]:
130    """Build the (parser, query) pair for ``comment_type``.
131
132    ``src_path`` only matters for ``CommentType.ts``, whose grammar varies by
133    file suffix (see ``ts_grammar_key``); every other comment type ignores it
134    and uses a single grammar. When ``src_path`` is omitted the TSX grammar is
135    assumed, which is the safe default for the whole JS/TS family except
136    TypeScript's own module variants.
137    """
138    if comment_type == CommentType.cpp:
139        import tree_sitter_cpp
140
141        parsed_language = Language(tree_sitter_cpp.language())
142        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
143    elif comment_type == CommentType.python:
144        import tree_sitter_python
145
146        parsed_language = Language(tree_sitter_python.language())
147        query = Query(parsed_language, PYTHON_QUERY)
148    elif comment_type == CommentType.cs:
149        import tree_sitter_c_sharp
150
151        parsed_language = Language(tree_sitter_c_sharp.language())
152        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
153    elif comment_type == CommentType.ts:
[docs]154        # @TypeScript comment query for tree-sitter, IMPL_TS_3, impl, [FE_TS]
155        import tree_sitter_typescript
156
157        # Legacy angle-bracket type assertions (``<T>x``) are valid TypeScript
158        # syntax in .ts/.mts/.cts, but the same text is JSX syntax under the TSX
159        # grammar: ``<string>x`` parses as a jsx_opening_element and swallows the
160        # rest of the file into a single jsx_text node, silently dropping every
161        # marker after it. So the plain TypeScript grammar is required for those
162        # three suffixes. The TSX grammar remains the fallback for the rest of
163        # the JS/TS family (.tsx, .jsx, .js, .mjs, .cjs): JavaScript has no such
164        # cast syntax, so TSX is safe there, and it additionally handles JSX
165        # embedded in plain .js.
166        if src_path is not None and ts_grammar_key(src_path) == "typescript":
167            parsed_language = Language(tree_sitter_typescript.language_typescript())
168        else:
169            parsed_language = Language(tree_sitter_typescript.language_tsx())
170        query = Query(parsed_language, TYPE_SCRIPT_QUERY)
171    elif comment_type == CommentType.yaml:
172        import tree_sitter_yaml
173
174        parsed_language = Language(tree_sitter_yaml.language())
175        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
176    elif comment_type == CommentType.rust:
177        import tree_sitter_rust
178
179        parsed_language = Language(tree_sitter_rust.language())
180        query = Query(parsed_language, RUST_QUERY)
181    elif comment_type == CommentType.go:
[docs]182        # @Go comment query for tree-sitter, IMPL_GO_3, impl, [FE_GO]
183        import tree_sitter_go
184
185        parsed_language = Language(tree_sitter_go.language())
186        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
187    elif comment_type == CommentType.jsonc:
188        import tree_sitter_json
189
190        parsed_language = Language(tree_sitter_json.language())
191        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
192    elif comment_type == CommentType.bash:
[docs]193        # @Bash comment query for tree-sitter, IMPL_BASH_3, impl, [FE_BASH]
194        import tree_sitter_bash
195
196        parsed_language = Language(tree_sitter_bash.language())
197        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
198    else:
199        raise ValueError(f"Unsupported comment style: {comment_type}")
200    parser = Parser(parsed_language)
201    return parser, query
202
203
204def parse_source(
205    src_string: bytes, parser: Parser, comment_type: CommentType, src_path: Path
206) -> tuple[Tree, CppNames | None]:
207    """The one parse of a source file the analysis reads, and, for a C++ file, what the
208    whole file decides about the names of its entities (:class:`CppNames`).
209
210    Every analysis of a file's markers parses it here, so that every C++ name is made
211    the one way. A C++ file is parsed from a copy with the namespace macros it defines
212    itself rewritten (:mod:`sphinx_codelinks.analyse.cpp_macros`): the tree's offsets
213    are the file's, and every comment, name, tagged scope and excerpt is read from
214    ``src_string``, through :class:`CppNames` and ``SourceFile.node_text``, never from
215    the copy. A ``.c`` file is C: it is parsed as written, and gets no file-level names.
216
217    The whole buffer is parsed in one piece: read one byte at a time, a multi-byte
218    UTF-8 character inside a token (a YAML scalar, an identifier, a Rust, Go or C#
219    character literal) is lexed as invalid, which loses every YAML comment after it
220    and the scope a marker binds to in bash, YAML or Go. Do not reintroduce a read
221    callback: a chunk boundary inside a character breaks the parse the same way (a
222    from-offset chunk of four bytes or more happens to work; a block-aligned one
223    does not).
224    """
225    if comment_type != CommentType.cpp or not cpp_macros.applies(src_path):
226        return parser.parse(src_string), None
227    tree, macros = cpp_macros.parse(parser, src_string)
228    return tree, CppNames(src_string, tree.root_node, macros)
229
230
231def captured_comments(tree: Tree, query: Query) -> list[TreeSitterNode] | None:
232    """The comment nodes ``query`` captures in ``tree``."""
233    query_cursor = QueryCursor(query)
234    captures: dict[str, list[TreeSitterNode]] = query_cursor.captures(tree.root_node)
235    return captures.get("comment")
236
237
[docs]238# @Comment extraction from source code using tree-sitter, IMPL_EXTR_1, impl, [FE_DEF]
239def extract_comments(
240    src_string: bytes, parser: Parser, query: Query
241) -> list[TreeSitterNode] | None:
242    """Get all comments from source files by tree-sitter.
243
244    The buffer is parsed whole, as :func:`parse_source` explains; the analysis parses
245    through :func:`parse_source`, and this is its comment capture alone.
246    """
247    return captured_comments(parser.parse(src_string), query)
248
249
250def find_enclosing_scope(
251    node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
252) -> TreeSitterNode | None:
253    """The nearest entity ``node`` is inside of, if any. A wrapper ``node`` is inside of
254    (a decorated definition, a template) counts as the entity it wraps."""
255    current = node.parent
256    while current is not None:
257        if is_entity(current, comment_type):
258            return current
259        if is_wrapper(current, comment_type):
260            wrapped = unwrap_entity(current, comment_type)
261            if wrapped is not None:
262                return wrapped
263        current = current.parent
264    return None
265
266
267def find_next_scope(
268    node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
269) -> TreeSitterNode | None:
270    """The entity right after the comment ``node``, if only trivia stands between.
271
272    Comments, blank lines and the language's other trivia (attributes, decorators) are
273    skipped; anything else, a statement or a declaration that is no entity, ends the
274    search. A comment Python's grammar places between a header (``def f():``) and its
275    body is followed by the body's first statement; in every other grammar a block
276    after a comment is the body of the entity the comment is inside. The entity found
277    is looked through its wrappers (``@decorator``, ``template <…>``, ``export``).
278    """
279    sibling = node.next_named_sibling
280    while sibling is not None and is_trivia(sibling, comment_type):
281        sibling = sibling.next_named_sibling
282    if (
283        comment_type == CommentType.python
284        and sibling is not None
285        and sibling.type == "block"
286    ):
287        sibling = next(
288            (c for c in sibling.named_children if not is_trivia(c, comment_type)),
289            None,
290        )
291    return unwrap_entity(sibling, comment_type)
292
293
294def _code_before_on_row(node: TreeSitterNode) -> bool:
295    """Whether code precedes the comment ``node`` on its first row.
296
297    Walks the previous siblings, anonymous tokens included and comments skipped,
298    climbing to the parent when there are none. A token that ends at column 0 of the
299    row (a preprocessor directive's newline) is not on it, and an entity's head (an
300    attribute, a decorator, a template header) is no code: the item it opens follows.
301    """
302    current: TreeSitterNode | None = node
303    while current is not None:
304        previous = current.prev_sibling
305        while previous is not None and previous.type in COMMENT_TYPES:
306            previous = previous.prev_sibling
307        if previous is not None:
308            if previous.type in ENTITY_HEAD_TYPES:
309                return False
310            return (
311                previous.end_point.row == node.start_point.row
312                and previous.end_point.column > 0
313            )
314        current = current.parent
315    return False
316
317
318def _entity_on_row(
319    node: TreeSitterNode, comment_type: CommentType
320) -> TreeSitterNode | None:
321    """The entity that ends on the trailing comment ``node``'s row, before it."""
322    previous = node.prev_named_sibling
323    while previous is not None and previous.type in COMMENT_TYPES:
324        previous = previous.prev_named_sibling
325    if previous is not None and previous.end_point.row == node.start_point.row:
326        return unwrap_entity(previous, comment_type)
327    return None
328
329
330def _is_file_header(node: TreeSitterNode) -> bool:
331    """Whether the comment ``node`` is one of the comments before the file's first item,
332    and a blank line separates the last of them from that item."""
333    parent = node.parent
334    if parent is None or parent.parent is not None:
335        return False
336    siblings = parent.children
337    index = 0
338    in_block = False
339    last_comment: TreeSitterNode | None = None
340    while index < len(siblings) and siblings[index].type in COMMENT_TYPES:
341        if siblings[index].id == node.id:
342            in_block = True
343        last_comment = siblings[index]
344        index += 1
345    if not in_block or last_comment is None or index >= len(siblings):
346        return in_block
347    return siblings[index].start_point.row - _last_row(last_comment) > 1
348
349
350def _last_row(comment: TreeSitterNode) -> int:
351    """The last row the ``comment`` node is written on."""
352    if comment.end_point.column == 0:
353        # a Rust doc line comment (``///``) takes its line break in its ``doc_comment``
354        # child, so its node ends at column 0 of the next row
355        return comment.end_point.row - 1
356    return comment.end_point.row
357
358
359# The tokens that open the line of a C or C++ preprocessor conditional, as the grammar
360# names them (``# if`` is ``#if`` too).
361_CONDITIONAL_DIRECTIVES = frozenset(
362    {"#if", "#ifdef", "#ifndef", "#elif", "#elifdef", "#elifndef", "#else", "#endif"}
363)
364
365
366def _directly_above_a_conditional(node: TreeSitterNode) -> bool:
367    """Whether the line of a preprocessor conditional (``#if`` ... ``#endif``) comes
368    right after the comment ``node``: past other comments only, and with no blank line
369    between.
370
371    When ``node`` ends a conditional's arm, what comes next is the arm's parent's: the
372    next ``#elif`` or ``#else``, or the ``#endif``.
373    """
374    last_row = _last_row(node)
375    current = node
376    while True:
377        following = current.next_sibling
378        outer = current
379        while following is None and outer.parent is not None:
380            outer = outer.parent
381            following = outer.next_sibling
382        if following is None or following.start_point.row - last_row > 1:
383            return False
384        if following.type not in COMMENT_TYPES:
385            break
386        last_row = _last_row(following)
387        current = following
388    while following.child_count:
389        following = following.children[0]
390    return following.type in _CONDITIONAL_DIRECTIVES
391
392
393class Unattached(Enum):
394    """Why :func:`associate` attached a comment to no entity, where the analysis
395    warns about it; every other comment on nothing is ``None``."""
396
397    above_conditional = "above_conditional"
398    """Rule 7: a C or C++ comment directly above a preprocessor conditional's line,
399    outside a function's body."""
400
401
402# A Doxygen or JSDoc tag saying a documentation comment documents the file, not the
403# item after it: at the start of the text or after ASCII whitespace, ``*``, ``/`` or
404# ``!``, and ending the word, which is ASCII (as Doxygen and JSDoc commands are).
405_FILE_DOC_TAG = re.compile(
406    r"(?:^|(?<=[ \t\n\r\f\v*/!]))[@\\]"
407    r"(?:file|fileoverview|packageDocumentation|module)(?![A-Za-z0-9_])"
408)
409
410
411def documents_file(text: str) -> bool:
412    """Whether a documentation comment's ``text`` says it documents the file.
413
414    It does when a file tag (Doxygen's file command, JSDoc's fileoverview, module and
415    packageDocumentation tags, see ``_FILE_DOC_TAG``) appears as a whole word, at the
416    start or after whitespace or one of ``*/!``; an address such as ``a@file.com`` or a
417    longer word such as ``@filename`` does not count.
418    """
419    return _FILE_DOC_TAG.search(text) is not None
420
421
422def _in_head_of(
423    node: TreeSitterNode, entity: TreeSitterNode, comment_type: CommentType
424) -> bool:
425    """Whether the comment ``node`` sits in ``entity``'s head: inside, or right after, an
426    attribute, a decorator or a template header that ``entity`` (or its wrapper) owns."""
427    parent = node.parent
428    head = parent if parent is not None and parent.type in ENTITY_HEAD_TYPES else None
429    if head is None:
430        previous = node.prev_sibling
431        while previous is not None and previous.type in COMMENT_TYPES:
432            previous = previous.prev_sibling
433        if previous is not None and previous.type in ENTITY_HEAD_TYPES:
434            head = previous
435    owner = head.parent if head is not None else None
436    if owner is None:
437        return False
438    if owner.id == entity.id:
439        return True
440    if not is_wrapper(owner, comment_type):
441        return False
442    wrapped = unwrap_entity(owner, comment_type)
443    return wrapped is not None and wrapped.id == entity.id
444
445
446def _is_inner_doc(text: bytes, comment_type: CommentType) -> bool:
447    """Whether the comment ``text`` is a Rust inner doc comment (``//!``, ``/*!``): it
448    documents what it is inside."""
449    if comment_type != CommentType.rust:
450        return False
451    return text.startswith((b"//!", b"/*!"))
452
453
454def _find_yaml_structure_in_block_node(
455    block_node: TreeSitterNode,
456) -> TreeSitterNode | None:
457    """Find YAML structure elements within a block_node."""
458    for grandchild in block_node.named_children:
459        if grandchild.type == "block_mapping":
460            for ggchild in grandchild.named_children:
461                if ggchild.type == "block_mapping_pair":
462                    return ggchild
463        elif grandchild.type == "block_sequence":
464            for ggchild in grandchild.named_children:
465                if ggchild.type == "block_sequence_item":
466                    return ggchild
467    return None
468
469
470def find_yaml_next_structure(node: TreeSitterNode) -> TreeSitterNode | None:
471    """Find the next YAML structure element after the comment node."""
472    current = node.next_named_sibling
473    while current:
474        if current.type in {
475            "block_mapping_pair",
476            "block_sequence_item",
477            "flow_mapping",
478            "flow_sequence",
479        }:
480            return current
481        if current.type == "document":
482            for child in current.named_children:
483                if child.type == "block_node":
484                    result = _find_yaml_structure_in_block_node(child)
485                    if result:
486                        return result
487        if current.type == "block_node":
488            result = _find_yaml_structure_in_block_node(current)
489            if result:
490                return result
491        current = current.next_named_sibling
492    return None
493
494
495def find_prev_sibling_on_same_row(node: TreeSitterNode) -> TreeSitterNode | None:
496    """Find a previous named sibling that is on the same row as the comment.
497
498    Grammar-agnostic: used to detect inline comments in both YAML and JSONC.
499    """
500    comment_row = node.start_point.row
501    current = node.prev_named_sibling
502
503    while current:
504        # Check if this sibling ends on the same row as the comment starts
505        # This indicates it's an inline comment
506        if current.end_point.row == comment_row:
507            return current
508        # If we find a sibling that ends before the comment row, we can stop
509        # as we won't find any siblings on the same row going backwards
510        if current.end_point.row < comment_row:
511            break
512        current = current.prev_named_sibling
513
514    return None
515
516
517def _yaml_associate(node: TreeSitterNode) -> Association | None:
518    """The YAML structure (key-value pair, list item, etc.) a comment is attached to."""
519    # First, check if this is an inline comment by looking for a previous sibling on the same row
520    prev_sibling_same_row = find_prev_sibling_on_same_row(node)
521    if prev_sibling_same_row:
522        return Association(prev_sibling_same_row, "trailing")
523
524    # If no previous sibling on same row, try to find the next named sibling (structure after the comment)
525    structure = find_yaml_next_structure(node)
526    if structure:
527        return Association(structure, "leading")
528
529    # If no next sibling found, traverse up to find parent structure
530    parent = node.parent
531    while parent:
532        if parent.type in {"block_mapping_pair", "block_sequence_item"}:
533            return Association(parent, "enclosing")
534        parent = parent.parent
535
536    return None
537
538
539def find_yaml_associated_structure(node: TreeSitterNode) -> TreeSitterNode | None:
540    """Find the YAML structure (key-value pair, list item, etc.) associated with a comment."""
541    association = _yaml_associate(node)
542    return association.node if association is not None else None
543
544
[docs]545# @JSONC comment-to-structure association, IMPL_JSONC_2, impl, [FE_JSONC]
546def _jsonc_associate(node: TreeSitterNode) -> Association | None:
547    """Find the JSON structure (key/value pair, value, list item) for a comment.
548
549    JSON is data rather than code, so association follows the same intent as YAML:
550    an inline comment belongs to the value on its row, a leading comment belongs to
551    the following structure, otherwise it belongs to the enclosing structure.
552    """
553    # Inline comment: a value/pair on the same row, before the comment
554    prev_sibling_same_row = find_prev_sibling_on_same_row(node)
555    if prev_sibling_same_row:
556        return Association(prev_sibling_same_row, "trailing")
557
558    # Leading comment: the next structure following the comment
559    current = node.next_named_sibling
560    while current:
561        if current.type in JSON_STRUCTURE_TYPES:
562            return Association(current, "leading")
563        current = current.next_named_sibling
564
565    # Otherwise: the enclosing structure
566    parent = node.parent
567    while parent:
568        if parent.type in {"pair", "object", "array"}:
569            return Association(parent, "enclosing")
570        parent = parent.parent
571
572    return None
573
574
575def find_jsonc_associated_structure(node: TreeSitterNode) -> TreeSitterNode | None:
576    """Find the JSON structure (key/value pair, value, list item) for a comment."""
577    association = _jsonc_associate(node)
578    return association.node if association is not None else None
579
580
[docs]581# @Attach a marker to the adjacent code entity, IMPL_ENTITY_1, impl, [FE_DEF]
582def associate(
583    node: TreeSitterNode,
584    comment_type: CommentType = CommentType.cpp,
585    *,
586    text: bytes | None = None,
587) -> Association | Unattached | None:
588    """The entity a comment is attached to, and how (see :data:`Mechanism`).
589
590    The rules, in order:
591
592    1. YAML and JSONC: the data structure on the comment's row, else the next one,
593       else the enclosing one.
594    2. A Python docstring documents the definition it is in.
595    3. A Rust inner doc comment (``//!``) documents what it is inside.
596    4. A trailing comment (code before it on its row) belongs to the entity on its
597       row, else to the enclosing one; an attribute, a decorator or a template header
598       before it is no code.
599    5. The comments before the file's first item, when a blank line separates the last
600       of them from it, belong to the file: no entity. A documentation comment is
601       exempt: it documents the next item, blank lines or not (rustc, Doxygen) --
602       unless it says it documents the file (``@file``, ``@fileoverview``).
603    6. A leading comment belongs to the entity right after it, when only trivia
604       stands between (comments, blank lines, attributes, decorators), through its
605       wrappers; never across other code.
606    7. A C or C++ comment directly above the line of a preprocessor conditional
607       (``#if``, ``#ifdef``, ``#ifndef``, ``#elif``, ``#elifdef``, ``#elifndef``,
608       ``#else``, ``#endif``, as the grammar reads them) -- past other comments,
609       with no blank line between -- belongs to no entity anywhere outside a
610       function's body (at file level, in a namespace, class, struct, union or enum
611       body, or in an ``extern "C"`` block): clang attaches a comment to no
612       declaration across a ``#`` line. With a blank line between, or in a
613       function's body (a lambda's, or a class defined in one, included), rule 8
614       holds.
615    8. Otherwise, the enclosing entity, if any.
616
617    A documentation comment attached by rule 4 or 6 is a ``doc_comment``. By rule 8, a
618    comment in the entity's head (after an attribute, a decorator or a template header
619    the entity owns) leads it, and a trailing comment whose entity starts on its row
620    (``def f():  # c``) is ``trailing``; any other is ``enclosing``.
621
622    :param node: The comment node, or a Python docstring's ``expression_statement``.
623    :param comment_type: The language of the file ``node`` was parsed from.
624    :param text: The comment's text as the file writes it (``SourceComment.text``),
625        where a C++ tree is parsed from a prepared copy; ``node.text`` when not given.
626    :return: The association; :attr:`Unattached.above_conditional` when rule 7
627        attaches the comment to no entity, and ``None`` when another rule does (file
628        level).
629    """
630    if comment_type == CommentType.yaml:
631        # YAML uses different structure association logic
632        return _yaml_associate(node)
633
634    if comment_type == CommentType.jsonc:
635        # JSONC uses data-aware structure association logic
636        return _jsonc_associate(node)
637
638    written = (node.text or b"") if text is None else text
639    declared: Mechanism = "doc_comment"
640    if node.type == CommentCategory.docstring or _is_inner_doc(written, comment_type):
641        scope = find_enclosing_scope(node, comment_type)
642        return Association(scope, declared) if scope is not None else None
643    is_doc = is_doc_comment(node, comment_type, text=written)
644
645    def enclosing(trailing: bool = False) -> Association | None:
646        scope = find_enclosing_scope(node, comment_type)
647        if scope is None:
648            return None
649        if _in_head_of(node, scope, comment_type):
650            return Association(scope, declared if is_doc else "leading")
651        if trailing and scope.start_point.row == node.start_point.row:
652            return Association(scope, declared if is_doc else "trailing")
653        return Association(scope, "enclosing")
654
655    if _code_before_on_row(node):
656        entity = _entity_on_row(node, comment_type)
657        if entity is None:
658            return enclosing(trailing=True)
659        return Association(entity, declared if is_doc else "trailing")
660    if _is_file_header(node) and (
661        not is_doc or documents_file(written.decode("utf-8", "replace"))
662    ):
663        return None
664    entity = find_next_scope(node, comment_type)
665    if entity is None:
666        if (
667            comment_type == CommentType.cpp
668            and not in_function_body(node)
669            and _directly_above_a_conditional(node)
670        ):
671            return Unattached.above_conditional
672        return enclosing()
673    return Association(entity, declared if is_doc else "leading")
674
675
676def find_associated_scope(
677    node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
678) -> TreeSitterNode | None:
679    """Find the associated scope of a comment: the node :func:`associate` binds."""
680    association = associate(node, comment_type)
681    return association.node if isinstance(association, Association) else None
682
683
684def _git_dir(path: Path) -> Path | None:
685    """The git directory for a checkout rooted at ``path``, or None if it is not one.
686
687    In a plain checkout ``.git`` is a directory. In a LINKED WORKTREE it is a file
688    holding ``gitdir: <path>`` -- git's documented gitfile format -- and the real
689    directory is elsewhere, under the main repository's ``.git/worktrees/<name>``.
690    Treating the file as "not a repository" is what made every worktree of this
691    repository look unversioned (useblocks/sphinx-codelinks#106).
692    """
693    dot_git = path / ".git"
694    if dot_git.is_dir():
695        return dot_git
696    if dot_git.is_file():
697        content = dot_git.read_text(encoding="utf-8", errors="replace").strip()
698        if content.startswith("gitdir:"):
699            target = Path(content.split(":", 1)[1].strip())
700            if not target.is_absolute():
701                target = (path / target).resolve()
702            if target.is_dir():
703                return target
704    return None
705
706
707def _git_common_dir(git_dir: Path) -> Path:
708    """The repository-wide git directory that ``git_dir`` belongs to.
709
710    A linked worktree's git directory carries a ``commondir`` file naming the main
711    repository's, and that is where ``config`` and every shared ref live -- only
712    per-worktree state (``HEAD``, ``refs/bisect``, ``refs/worktree``) is local. A plain
713    checkout has no ``commondir`` and the two are the same directory.
714    """
715    commondir = git_dir / "commondir"
716    if not commondir.is_file():
717        return git_dir
718    target = Path(commondir.read_text(encoding="utf-8").strip())
719    if not target.is_absolute():
720        target = (git_dir / target).resolve()
721    return target
722
723
724def find_git_root(src_dir: Path) -> Path | None:
725    """The git root :func:`locate_git_root` finds, without its warning when none."""
726    current = src_dir.resolve()
727    parents = list(current.parents)
728    parents.append(current)
729    for parent in parents:
730        if _git_dir(parent) is not None:
731            return parent
732    return None
733
734
735def locate_git_root(src_dir: Path) -> Path | None:
736    """Traverse upwards to find git root."""
737    found = find_git_root(src_dir)
738    if found is not None:
739        return found
740    logger.warning(
741        f"git root is not found in the parent of {src_dir}",
742        subtype="git_root",
743        location=str(src_dir),
744    )
745    return None
746
747
748def get_remote_url(git_root: Path, remote_name: str = "origin") -> str | None:
749    """Get remote url from the git directory's config."""
750    git_dir = _git_dir(git_root)
751    # a worktree's remotes are the main repository's, so `config` is in the common dir
752    config_path = (
753        _git_common_dir(git_dir) if git_dir is not None else git_root / ".git"
754    ) / "config"
755    if not config_path.exists():
756        logger.warning(
757            f"{config_path} does not exist",
758            subtype="git_config",
759            location=str(config_path),
760        )
761        return None
762
763    config = configparser.ConfigParser(allow_no_value=True, strict=False)
764    config.read(config_path)
765    section = f'remote "{remote_name}"'
766    if section in config and "url" in config[section]:
767        url: str = config[section]["url"]
768        return url
769    logger.warning(
770        f"remote-url is not found in {config_path}",
771        subtype="git_remote",
772        location=str(config_path),
773    )
774    return None
775
776
777def _packed_ref(common_dir: Path, ref: str) -> str | None:
778    """The sha ``packed-refs`` records for ``ref``, or None.
779
780    A ref has no file of its own once ``git gc`` (or ``git pack-refs``) has run, which
781    happens unattended on any long-lived checkout -- so a loose-file lookup alone reports
782    "no rev" for a perfectly ordinary repository. The file is one ``<sha> <refname>`` per
783    line; ``^<sha>`` lines are the peeled targets of annotated tags and are skipped, as
784    is the leading ``#`` header.
785    """
786    packed = common_dir / "packed-refs"
787    if not packed.is_file():
788        return None
789    for line in packed.read_text(encoding="utf-8", errors="replace").splitlines():
790        if not line or line.startswith(("#", "^")):
791            continue
792        sha, _, name = line.partition(" ")
793        if name.strip() == ref:
794            return sha
795    return None
796
797
798def get_current_rev(git_root: Path) -> str | None:
799    """Get current commit rev from the git directory's HEAD."""
800    # HEAD is per-worktree, so it is read from the worktree's OWN git directory
801    git_dir = _git_dir(git_root) or git_root / ".git"
802    head_path = git_dir / "HEAD"
803    if not head_path.exists():
804        logger.warning(
805            f"{head_path} does not exist",
806            subtype="git_head",
807            location=str(head_path),
808        )
809        return None
810    head_content = head_path.read_text().strip()
811    if not head_content.startswith("ref: "):
812        # Detached HEAD (e.g. CI checkouts): .git/HEAD holds the commit SHA
813        # directly, which is exactly the rev we want.
814        return head_content
815
816    ref = head_content.split(":", 1)[1].strip()
817    common_dir = _git_common_dir(git_dir)
818    ref_path = git_dir / ref
819    if not ref_path.exists():
820        # every branch is a SHARED ref, so in a worktree it lives in the common
821        # directory; only refs/bisect and refs/worktree are per-worktree
822        ref_path = common_dir / ref
823    if ref_path.exists():
824        return ref_path.read_text().strip()
825    packed = _packed_ref(common_dir, ref)
826    if packed is not None:
827        return packed
828    logger.warning(
829        f"{ref_path} does not exist",
830        subtype="git_ref",
831        location=str(ref_path),
832    )
833    return None
834
835
836def form_https_url(
837    git_url: str, rev: str, project_path: Path, filepath: Path, lineno: int
838) -> str | None:
839    parsed_url = parse(git_url)
840    template = GIT_HOST_URL_TEMPLATE.get(parsed_url.platform)
841    if not template:
842        logger.warning(
843            f"Unsupported Git host: {parsed_url.platform}",
844            subtype="git_host",
845        )
846        return git_url
847    https_url = template.format(
848        owner=parsed_url.owner,
849        repo=parsed_url.repo,
850        rev=rev,
851        path=pathname2url(str(filepath.absolute().relative_to(project_path))),
852        lineno=str(lineno),
853    )
854    return https_url