1import configparser
  2from collections.abc import Callable
  3from pathlib import Path
  4from urllib.request import pathname2url
  5
  6from giturlparse import parse
  7from tree_sitter import Language, Parser, Query, QueryCursor
  8from tree_sitter import Node as TreeSitterNode
  9
 10from sphinx_codelinks.config import CommentCategory
 11from sphinx_codelinks.logger import get_logger
 12from sphinx_codelinks.source_discover.config import CommentType
 13
 14# Language-specific node types for scope detection.
 15#
 16# YAML and JSONC are intentionally absent. They are data formats, not code, so a
 17# comment associates with the surrounding data structure (key/value pair, list
 18# item, or scalar) rather than with an enclosing or following declaration. That
 19# needs a different algorithm (inline same-row association first, scalar targets,
 20# grammar-specific traversal), implemented in find_yaml_associated_structure and
 21# find_jsonc_associated_structure and dispatched from find_associated_scope.
 22# Those bespoke finders never read this table (only find_next_scope and
 23# find_enclosing_scope do), so an entry here would be dead.
 24SCOPE_NODE_TYPES = {
[docs] 25    # @Python Scope Node Types, IMPL_PY_2, impl, [FE_PY]
 26    CommentType.python: {"function_definition", "class_definition"},
[docs] 27    # @C and C++ Scope Node Types, IMPL_C_2, impl, [FE_C_SUPPORT, FE_CPP]
 28    CommentType.cpp: {"function_definition", "class_specifier", "struct_specifier"},
 29    CommentType.cs: {"method_declaration", "class_declaration", "property_declaration"},
[docs] 30    # @TypeScript Scope Node Types, IMPL_TS_2, impl, [FE_TS]
 31    # The last five are scopes only when they hold a function or a class: see
 32    # SCOPE_PREDICATES.
 33    CommentType.ts: {
 34        "function_declaration",
 35        "generator_function_declaration",
 36        "function_signature",
 37        "class_declaration",
 38        "abstract_class_declaration",
 39        "interface_declaration",
 40        "type_alias_declaration",
 41        "enum_declaration",
 42        "module",
 43        "internal_module",
 44        "method_definition",
 45        "method_signature",
 46        "abstract_method_signature",
 47        "lexical_declaration",
 48        "variable_declaration",
 49        "public_field_definition",
 50        "assignment_expression",
 51        "export_statement",
 52    },
[docs] 53    # @Rust Scope Node Types, IMPL_RUST_2, impl, [FE_RUST]
 54    CommentType.rust: {
 55        "function_item",
 56        "struct_item",
 57        "enum_item",
 58        "impl_item",
 59        "trait_item",
 60        "mod_item",
 61    },
[docs] 62    # @Go Scope Node Types, IMPL_GO_4, impl, [FE_GO]
 63    CommentType.go: {
 64        "function_declaration",
 65        "method_declaration",
 66        "type_declaration",
 67        "type_spec",
 68    },
[docs] 69    # @Bash Scope Node Types, IMPL_BASH_2, impl, [FE_BASH]
 70    CommentType.bash: {"function_definition"},
[docs] 71    # @VHDL Scope Node Types, IMPL_VHDL_2, impl, [FE_VHDL]
 72    # VHDL's design elements, which find_vhdl_associated_scope binds a marker to.
 73    # Sequential statements are not among them: a marker among those documents its
 74    # process or subprogram.
 75    CommentType.vhdl: {
 76        # design units
 77        "entity_declaration",
 78        "architecture_definition",
 79        "package_declaration",
 80        "package_definition",
 81        "package_instantiation_declaration",
 82        "configuration_declaration",
 83        "context_declaration",
 84        # subprograms
 85        "subprogram_declaration",
 86        "subprogram_definition",
 87        "subprogram_instantiation_declaration",
 88        # declarations
 89        "type_declaration",
 90        "subtype_declaration",
 91        "component_declaration",
 92        "constant_declaration",
 93        "signal_declaration",
 94        "variable_declaration",
 95        "file_declaration",
 96        "alias_declaration",
 97        # generics, ports and parameters
 98        "interface_declaration",
 99        "interface_constant_declaration",
100        "interface_signal_declaration",
101        "interface_variable_declaration",
102        "interface_file_declaration",
103        "interface_type_declaration",
104        "interface_subprogram_declaration",
105        "interface_package_declaration",
106        # concurrent statements
107        "process_statement",
108        "block_statement",
109        "for_generate_statement",
110        "if_generate_statement",
111        "case_generate_statement",
112        "component_instantiation_statement",
113        "concurrent_simple_signal_assignment",
114        "concurrent_conditional_signal_assignment",
115        "concurrent_selected_signal_assignment",
116        "concurrent_assertion_statement",
117        "concurrent_procedure_call_statement",
118    },
119}
120
121logger = get_logger(__name__)
122
123GIT_HOST_URL_TEMPLATE = {
124    "github": "https://github.com/{owner}/{repo}/blob/{rev}/{path}#L{lineno}",
125    "gitlab": "https://gitlab.com/{owner}/{repo}/-/blob/{rev}/{path}#L{lineno}",
126}
127
128PYTHON_QUERY = """
129                ; Match comments
130                (comment) @comment
131
132                ; Match docstrings inside modules, functions, or classes
133                (module (expression_statement (string)) @comment)
134                (function_definition (block (expression_statement (string)) @comment))
135                (class_definition (block (expression_statement (string)) @comment))
136            """
137# The query of every grammar whose comments are one ``comment`` node kind.
138SIMPLE_COMMENT_QUERY = """(comment) @comment"""
139# ``html_comment`` is a separate node kind the TypeScript/TSX grammars emit
140# for ``<!--`` comments: legacy one-line comments, valid in classic scripts only.
141# Without matching it, markers written in that style are silently dropped.
142TYPE_SCRIPT_QUERY = """
143    (comment) @comment
144    (html_comment) @comment
145"""
146RUST_QUERY = """
147    (line_comment) @comment
148    (block_comment) @comment
149"""
150
151# JSON value node types that can be associated with a comment.
152JSON_STRUCTURE_TYPES = {
153    "pair",
154    "object",
155    "array",
156    "string",
157    "number",
158    "true",
159    "false",
160    "null",
161}
162
163# TypeScript's own module variants. Legacy angle-bracket type assertions
164# (``<T>x``) are valid syntax only here, not under the TSX grammar (see the
165# CommentType.ts branch of init_tree_sitter for what goes wrong otherwise), so
166# these three suffixes get the plain TypeScript grammar and everything else in
167# the JS/TS family falls back to TSX.
168TS_STRICT_GRAMMAR_SUFFIXES = {".ts", ".mts", ".cts"}
169
170
171def ts_grammar_key(src_path: Path) -> str:
172    """Return which tree-sitter-typescript grammar ``src_path`` needs.
173
174    ``"typescript"`` for TypeScript's own module variants (``.ts``, ``.mts``,
175    ``.cts``); ``"tsx"`` for the rest of the JavaScript/TypeScript family
176    (``.tsx``, ``.jsx``, ``.js``, ``.mjs``, ``.cjs``). Used both to pick the
177    grammar in ``init_tree_sitter`` and, by callers that parse many files, to
178    cache one parser per grammar instead of rebuilding one per file.
179    """
180    return (
181        "typescript" if src_path.suffix.lower() in TS_STRICT_GRAMMAR_SUFFIXES else "tsx"
182    )
183
184
185def is_text_file(filepath: Path, sample_size: int = 2048) -> bool:
186    """Return True if file is likely text, False if binary."""
187    try:
188        with filepath.open("rb") as f:
189            chunk = f.read(sample_size)
190        # Quick binary heuristic: null byte present
191        if b"\x00" in chunk:
192            return False
193        # Try UTF-8 decode on the sample
194        chunk.decode("utf-8")
195        return True
196    except UnicodeDecodeError:
197        return False
198
199
[docs]200# @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_VHDL]
201def init_tree_sitter(
202    comment_type: CommentType, src_path: Path | None = None
203) -> tuple[Parser, Query]:
204    """Build the (parser, query) pair for ``comment_type``.
205
206    ``src_path`` only matters for ``CommentType.ts``, whose grammar varies by
207    file suffix (see ``ts_grammar_key``); every other comment type ignores it
208    and uses a single grammar. When ``src_path`` is omitted the TSX grammar is
209    assumed, which is the safe default for the whole JS/TS family except
210    TypeScript's own module variants.
211    """
212    if comment_type == CommentType.cpp:
213        import tree_sitter_cpp
214
215        parsed_language = Language(tree_sitter_cpp.language())
216        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
217    elif comment_type == CommentType.python:
218        import tree_sitter_python
219
220        parsed_language = Language(tree_sitter_python.language())
221        query = Query(parsed_language, PYTHON_QUERY)
222    elif comment_type == CommentType.cs:
223        import tree_sitter_c_sharp
224
225        parsed_language = Language(tree_sitter_c_sharp.language())
226        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
227    elif comment_type == CommentType.ts:
[docs]228        # @TypeScript comment query for tree-sitter, IMPL_TS_3, impl, [FE_TS]
229        import tree_sitter_typescript
230
231        # Legacy angle-bracket type assertions (``<T>x``) are valid TypeScript
232        # syntax in .ts/.mts/.cts, but the same text is JSX syntax under the TSX
233        # grammar: ``<string>x`` parses as a jsx_opening_element and swallows the
234        # rest of the file into a single jsx_text node, silently dropping every
235        # marker after it. So the plain TypeScript grammar is required for those
236        # three suffixes. The TSX grammar remains the fallback for the rest of
237        # the JS/TS family (.tsx, .jsx, .js, .mjs, .cjs): JavaScript has no such
238        # cast syntax, so TSX is safe there, and it additionally handles JSX
239        # embedded in plain .js.
240        if src_path is not None and ts_grammar_key(src_path) == "typescript":
241            parsed_language = Language(tree_sitter_typescript.language_typescript())
242        else:
243            parsed_language = Language(tree_sitter_typescript.language_tsx())
244        query = Query(parsed_language, TYPE_SCRIPT_QUERY)
245    elif comment_type == CommentType.yaml:
246        import tree_sitter_yaml
247
248        parsed_language = Language(tree_sitter_yaml.language())
249        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
250    elif comment_type == CommentType.rust:
251        import tree_sitter_rust
252
253        parsed_language = Language(tree_sitter_rust.language())
254        query = Query(parsed_language, RUST_QUERY)
255    elif comment_type == CommentType.go:
[docs]256        # @Go comment query for tree-sitter, IMPL_GO_3, impl, [FE_GO]
257        import tree_sitter_go
258
259        parsed_language = Language(tree_sitter_go.language())
260        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
261    elif comment_type == CommentType.jsonc:
262        import tree_sitter_json
263
264        parsed_language = Language(tree_sitter_json.language())
265        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
266    elif comment_type == CommentType.bash:
[docs]267        # @Bash comment query for tree-sitter, IMPL_BASH_3, impl, [FE_BASH]
268        import tree_sitter_bash
269
270        parsed_language = Language(tree_sitter_bash.language())
271        query = Query(parsed_language, SIMPLE_COMMENT_QUERY)
272    elif comment_type == CommentType.vhdl:
[docs]273        # @VHDL comment query for tree-sitter, IMPL_VHDL_3, impl, [FE_VHDL]
274        import tree_sitter_vhdl
275
276        parsed_language = Language(tree_sitter_vhdl.language())
277        # VHDL names its ``--`` and ``/* */`` comments as Rust does
278        query = Query(parsed_language, RUST_QUERY)
279    else:
280        raise ValueError(f"Unsupported comment style: {comment_type}")
281    parser = Parser(parsed_language)
282    return parser, query
283
284
[docs]285# @Comment extraction from source code using tree-sitter, IMPL_EXTR_1, impl, [FE_DEF]
286def extract_comments(
287    src_string: bytes, parser: Parser, query: Query
288) -> list[TreeSitterNode] | None:
289    """Get all comments from source files by tree-sitter.
290
291    The whole buffer is parsed in one piece: read one byte at a time, a multi-byte
292    UTF-8 character inside a token (a YAML scalar, an identifier, a Rust, Go or C#
293    character literal) is lexed as invalid, which loses every YAML comment after it
294    and the scope a marker binds to in bash, YAML or Go. Do not reintroduce a read
295    callback: a chunk boundary inside a character breaks the parse the same way (a
296    from-offset chunk of four bytes or more happens to work; a block-aligned one
297    does not).
298    """
299    tree = parser.parse(src_string)
300    query_cursor = QueryCursor(query)
301    captures: dict[str, list[TreeSitterNode]] = query_cursor.captures(tree.root_node)
302
303    return captures.get("comment")
304
305
306# What a TypeScript declaration's value can be wrapped in without changing what it
307# is: ``(() => {}) as Handler``, ``<Handler>(() => {})``, ``f!``, ``x satisfies T``.
308TS_VALUE_WRAPPER_TYPES = {
309    "parenthesized_expression",
310    "as_expression",
311    "satisfies_expression",
312    "non_null_expression",
313    "type_assertion",
314}
315# The values that make a declaration a scope.
316TS_FUNCTION_VALUE_TYPES = {
317    "arrow_function",
318    "function_expression",
319    "generator_function",
320    "class",
321}
322
323
324def _is_function_like(value: TreeSitterNode | None) -> bool:
325    """True if ``value``, seen through its wrappers, is a function or a class."""
326    while value is not None and value.type in TS_VALUE_WRAPPER_TYPES:
327        children = value.named_children
328        if not children:
329            return False
330        # ``<T>x`` names its type first, every other wrapper its expression
331        value = children[-1] if value.type == "type_assertion" else children[0]
332    return value is not None and value.type in TS_FUNCTION_VALUE_TYPES
333
334
335def _declares_function(node: TreeSitterNode) -> bool:
336    """A ``const``/``let``/``var`` declaration one of whose declarators is a function,
337    or one declared with ``declare`` (``declare const x: T``), which holds no value and
338    is the declared thing itself. Only the ``declare``-prefixed form: a variable inside a
339    ``declare namespace``, ``module`` or ``global`` body, or a bare ``export const x: T;``
340    in a ``.d.ts``, is no scope here and binds the next declaration."""
341    if node.parent is not None and node.parent.type == "ambient_declaration":
342        return True
343    return any(
344        _is_function_like(declarator.child_by_field_name("value"))
345        for declarator in node.named_children
346        if declarator.type == "variable_declarator"
347    )
348
349
350def _value_is_function(node: TreeSitterNode) -> bool:
351    """A class field (``handler = () => {}``) or a default export of a function or class."""
352    return _is_function_like(node.child_by_field_name("value"))
353
354
355def _assigns_function(node: TreeSitterNode) -> bool:
356    """An assignment of a function, as ``module.exports = function () {}`` is."""
357    return _is_function_like(node.child_by_field_name("right"))
358
359
360# Node types a scope can be wrapped in, per language: when the next sibling of a comment
361# is one of them, its children are searched for the scope. Every language has ``block``.
362DEFAULT_SCOPE_CONTAINER_TYPES = frozenset({"block"})
363SCOPE_CONTAINER_TYPES: dict[CommentType, frozenset[str]] = {
364    # ``export …``, ``declare …``, and an expression statement holding an assignment
365    # or a top-level ``namespace``
366    CommentType.ts: DEFAULT_SCOPE_CONTAINER_TYPES
367    | {"export_statement", "ambient_declaration", "expression_statement"},
368}
369
370# Scope node types that are a scope only when their predicate holds, per language, so
371# that a comment does not bind to a declaration that merely precedes the one it
372# documents (``const flag = true;`` before a function).
373SCOPE_PREDICATES: dict[CommentType, dict[str, Callable[[TreeSitterNode], bool]]] = {
374    CommentType.ts: {
375        "lexical_declaration": _declares_function,
376        "variable_declaration": _declares_function,
377        "public_field_definition": _value_is_function,
378        "assignment_expression": _assigns_function,
379        "export_statement": _value_is_function,
380    },
381}
382
383
384def _matches_scope(node: TreeSitterNode, comment_type: CommentType) -> bool:
385    if node.type not in SCOPE_NODE_TYPES.get(comment_type, ()):
386        return False
387    predicate = SCOPE_PREDICATES.get(comment_type, {}).get(node.type)
388    return predicate is None or predicate(node)
389
390
391def find_enclosing_scope(
392    node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
393) -> TreeSitterNode | None:
394    """Find the enclosing scope of a comment."""
395    current: TreeSitterNode = node
396    while current:
397        if _matches_scope(current, comment_type):
398            return current
399        current: TreeSitterNode | None = current.parent  # required for node traversal
400    return None
401
402
403def find_next_scope(
404    node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
405) -> TreeSitterNode | None:
406    """Find the next scope of a comment."""
407    containers = SCOPE_CONTAINER_TYPES.get(comment_type, DEFAULT_SCOPE_CONTAINER_TYPES)
408    current: TreeSitterNode = node
409    while current:
410        if _matches_scope(current, comment_type):
411            return current
412        current: TreeSitterNode | None = (
413            current.next_named_sibling
414        )  # required for node traversal
415        if current and current.type in containers:
416            for child in current.named_children:
417                if _matches_scope(child, comment_type):
418                    return child
419                # a container in a container, as ``export declare …`` nests an
420                # ``ambient_declaration`` in an ``export_statement``
421                if child.type in containers and child.type != "block":
422                    for grandchild in child.named_children:
423                        if _matches_scope(grandchild, comment_type):
424                            return grandchild
425
426    return None
427
428
429def _find_yaml_structure_in_block_node(
430    block_node: TreeSitterNode,
431) -> TreeSitterNode | None:
432    """Find YAML structure elements within a block_node."""
433    for grandchild in block_node.named_children:
434        if grandchild.type == "block_mapping":
435            for ggchild in grandchild.named_children:
436                if ggchild.type == "block_mapping_pair":
437                    return ggchild
438        elif grandchild.type == "block_sequence":
439            for ggchild in grandchild.named_children:
440                if ggchild.type == "block_sequence_item":
441                    return ggchild
442    return None
443
444
445def find_yaml_next_structure(node: TreeSitterNode) -> TreeSitterNode | None:
446    """Find the next YAML structure element after the comment node."""
447    current = node.next_named_sibling
448    while current:
449        if current.type in {
450            "block_mapping_pair",
451            "block_sequence_item",
452            "flow_mapping",
453            "flow_sequence",
454        }:
455            return current
456        if current.type == "document":
457            for child in current.named_children:
458                if child.type == "block_node":
459                    result = _find_yaml_structure_in_block_node(child)
460                    if result:
461                        return result
462        if current.type == "block_node":
463            result = _find_yaml_structure_in_block_node(current)
464            if result:
465                return result
466        current = current.next_named_sibling
467    return None
468
469
470def find_prev_sibling_on_same_row(node: TreeSitterNode) -> TreeSitterNode | None:
471    """Find a previous named sibling that is on the same row as the comment.
472
473    Grammar-agnostic: used to detect inline comments in both YAML and JSONC.
474    """
475    comment_row = node.start_point.row
476    current = node.prev_named_sibling
477
478    while current:
479        # Check if this sibling ends on the same row as the comment starts
480        # This indicates it's an inline comment
481        if current.end_point.row == comment_row:
482            return current
483        # If we find a sibling that ends before the comment row, we can stop
484        # as we won't find any siblings on the same row going backwards
485        if current.end_point.row < comment_row:
486            break
487        current = current.prev_named_sibling
488
489    return None
490
491
492def find_yaml_associated_structure(node: TreeSitterNode) -> TreeSitterNode | None:
493    """Find the YAML structure (key-value pair, list item, etc.) associated with a comment."""
494    # First, check if this is an inline comment by looking for a previous sibling on the same row
495    prev_sibling_same_row = find_prev_sibling_on_same_row(node)
496    if prev_sibling_same_row:
497        return prev_sibling_same_row
498
499    # If no previous sibling on same row, try to find the next named sibling (structure after the comment)
500    structure = find_yaml_next_structure(node)
501    if structure:
502        return structure
503
504    # If no next sibling found, traverse up to find parent structure
505    parent = node.parent
506    while parent:
507        if parent.type in {"block_mapping_pair", "block_sequence_item"}:
508            return parent
509        parent = parent.parent
510
511    return None
512
513
[docs]514# @JSONC comment-to-structure association, IMPL_JSONC_2, impl, [FE_JSONC]
515def find_jsonc_associated_structure(node: TreeSitterNode) -> TreeSitterNode | None:
516    """Find the JSON structure (key/value pair, value, list item) for a comment.
517
518    JSON is data rather than code, so association follows the same intent as YAML:
519    an inline comment belongs to the value on its row, a leading comment belongs to
520    the following structure, otherwise it belongs to the enclosing structure.
521    """
522    # Inline comment: a value/pair on the same row, before the comment
523    prev_sibling_same_row = find_prev_sibling_on_same_row(node)
524    if prev_sibling_same_row:
525        return prev_sibling_same_row
526
527    # Leading comment: the next structure following the comment
528    current = node.next_named_sibling
529    while current:
530        if current.type in JSON_STRUCTURE_TYPES:
531            return current
532        current = current.next_named_sibling
533
534    # Otherwise: the enclosing structure
535    parent = node.parent
536    while parent:
537        if parent.type in {"pair", "object", "array"}:
538            return parent
539        parent = parent.parent
540
541    return None
542
543
544# The VHDL nodes that wrap the design elements a leading comment documents: a marker
545# above one binds the wrapper's first item. A design unit's first item is the one after
546# its context clauses.
547VHDL_SCOPE_WRAPPER_TYPES = frozenset(
548    {
549        "design_unit",
550        "process_head",
551        "block_head",
552        "generate_head",
553        "generate_block",
554        "interface_list",
555    }
556)
557VHDL_CONTEXT_CLAUSE_TYPES = frozenset(
558    {"library_clause", "use_clause", "context_reference"}
559)
560VHDL_COMMENT_TYPES = frozenset({"line_comment", "block_comment"})
561
562
563def _vhdl_innermost_on_row(node: TreeSitterNode, row: int) -> TreeSitterNode | None:
564    """The innermost design element of ``node``'s subtree, ``node`` included, that
565    ends on ``row``, following the last non-comment child that ends there."""
566    found = None
567    current: TreeSitterNode | None = node
568    while current is not None:
569        if _matches_scope(current, CommentType.vhdl):
570            found = current
571        current = next(
572            (
573                child
574                for child in reversed(current.named_children)
575                if child.type not in VHDL_COMMENT_TYPES and child.end_point.row == row
576            ),
577            None,
578        )
579    return found
580
581
[docs]582# @VHDL comment-to-design-element association, IMPL_VHDL_4, impl, [FE_VHDL]
583def find_vhdl_associated_scope(node: TreeSitterNode) -> TreeSitterNode | None:
584    """Find the VHDL design element a comment documents.
585
586    A trailing comment -- the previous non-comment sibling ends on the comment's row --
587    documents the innermost design element ending on that row, a port inside its
588    ``interface_list`` included. A leading comment documents the next non-comment
589    sibling, if that is a design element, or the first item of a wrapper; the walk
590    never passes a sibling. Otherwise the comment documents the nearest enclosing
591    design element, or nothing at file level. Comment siblings are skipped: a VHDL
592    ``line_comment`` holds its newline, so the comment on the row above ends on this one.
593    """
594    row = node.start_point.row
595    previous = node.prev_named_sibling
596    while previous is not None and previous.type in VHDL_COMMENT_TYPES:
597        previous = previous.prev_named_sibling
598    if previous is not None and previous.end_point.row == row:
599        return _vhdl_innermost_on_row(previous, row) or find_enclosing_scope(
600            node, CommentType.vhdl
601        )
602    following = node.next_named_sibling
603    while following is not None and following.type in VHDL_COMMENT_TYPES:
604        following = following.next_named_sibling
605    if following is not None and following.type in VHDL_SCOPE_WRAPPER_TYPES:
606        skipped = (
607            VHDL_CONTEXT_CLAUSE_TYPES
608            if following.type == "design_unit"
609            else frozenset()
610        )
611        following = next(
612            (
613                child
614                for child in following.named_children
615                if child.type not in VHDL_COMMENT_TYPES and child.type not in skipped
616            ),
617            None,
618        )
619    if following is not None and _matches_scope(following, CommentType.vhdl):
620        return following
621    return find_enclosing_scope(node, CommentType.vhdl)
622
623
624def find_associated_scope(
625    node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
626) -> TreeSitterNode | None:
627    """Find the associated scope of a comment."""
628    if comment_type == CommentType.vhdl:
629        # VHDL binds to the design element next to the comment, never past it
630        return find_vhdl_associated_scope(node)
631
632    if comment_type == CommentType.yaml:
633        # YAML uses different structure association logic
634        return find_yaml_associated_structure(node)
635
636    if comment_type == CommentType.jsonc:
637        # JSONC uses data-aware structure association logic
638        return find_jsonc_associated_structure(node)
639
640    if node.type == CommentCategory.docstring:
641        # Only for python's docstring
642        return find_enclosing_scope(node, comment_type)
643    # General comments regardless of comment types
644    associated_scope = find_next_scope(node, comment_type)
645    if not associated_scope:
646        associated_scope = find_enclosing_scope(node, comment_type)
647    return associated_scope
648
649
650def _git_dir(path: Path) -> Path | None:
651    """The git directory for a checkout rooted at ``path``, or None if it is not one.
652
653    In a plain checkout ``.git`` is a directory. In a LINKED WORKTREE it is a file
654    holding ``gitdir: <path>`` -- git's documented gitfile format -- and the real
655    directory is elsewhere, under the main repository's ``.git/worktrees/<name>``.
656    Treating the file as "not a repository" is what made every worktree of this
657    repository look unversioned (useblocks/sphinx-codelinks#106).
658    """
659    dot_git = path / ".git"
660    if dot_git.is_dir():
661        return dot_git
662    if dot_git.is_file():
663        content = dot_git.read_text(encoding="utf-8", errors="replace").strip()
664        if content.startswith("gitdir:"):
665            target = Path(content.split(":", 1)[1].strip())
666            if not target.is_absolute():
667                target = (path / target).resolve()
668            if target.is_dir():
669                return target
670    return None
671
672
673def _git_common_dir(git_dir: Path) -> Path:
674    """The repository-wide git directory that ``git_dir`` belongs to.
675
676    A linked worktree's git directory carries a ``commondir`` file naming the main
677    repository's, and that is where ``config`` and every shared ref live -- only
678    per-worktree state (``HEAD``, ``refs/bisect``, ``refs/worktree``) is local. A plain
679    checkout has no ``commondir`` and the two are the same directory.
680    """
681    commondir = git_dir / "commondir"
682    if not commondir.is_file():
683        return git_dir
684    target = Path(commondir.read_text(encoding="utf-8").strip())
685    if not target.is_absolute():
686        target = (git_dir / target).resolve()
687    return target
688
689
690def find_git_root(src_dir: Path) -> Path | None:
691    """The git root :func:`locate_git_root` finds, without its warning when none."""
692    current = src_dir.resolve()
693    parents = list(current.parents)
694    parents.append(current)
695    for parent in parents:
696        if _git_dir(parent) is not None:
697            return parent
698    return None
699
700
701def locate_git_root(src_dir: Path) -> Path | None:
702    """Traverse upwards to find git root."""
703    found = find_git_root(src_dir)
704    if found is not None:
705        return found
706    logger.warning(
707        f"git root is not found in the parent of {src_dir}",
708        subtype="git_root",
709        location=str(src_dir),
710    )
711    return None
712
713
714def get_remote_url(git_root: Path, remote_name: str = "origin") -> str | None:
715    """Get remote url from the git directory's config."""
716    git_dir = _git_dir(git_root)
717    # a worktree's remotes are the main repository's, so `config` is in the common dir
718    config_path = (
719        _git_common_dir(git_dir) if git_dir is not None else git_root / ".git"
720    ) / "config"
721    if not config_path.exists():
722        logger.warning(
723            f"{config_path} does not exist",
724            subtype="git_config",
725            location=str(config_path),
726        )
727        return None
728
729    config = configparser.ConfigParser(allow_no_value=True, strict=False)
730    config.read(config_path)
731    section = f'remote "{remote_name}"'
732    if section in config and "url" in config[section]:
733        url: str = config[section]["url"]
734        return url
735    logger.warning(
736        f"remote-url is not found in {config_path}",
737        subtype="git_remote",
738        location=str(config_path),
739    )
740    return None
741
742
743def _packed_ref(common_dir: Path, ref: str) -> str | None:
744    """The sha ``packed-refs`` records for ``ref``, or None.
745
746    A ref has no file of its own once ``git gc`` (or ``git pack-refs``) has run, which
747    happens unattended on any long-lived checkout -- so a loose-file lookup alone reports
748    "no rev" for a perfectly ordinary repository. The file is one ``<sha> <refname>`` per
749    line; ``^<sha>`` lines are the peeled targets of annotated tags and are skipped, as
750    is the leading ``#`` header.
751    """
752    packed = common_dir / "packed-refs"
753    if not packed.is_file():
754        return None
755    for line in packed.read_text(encoding="utf-8", errors="replace").splitlines():
756        if not line or line.startswith(("#", "^")):
757            continue
758        sha, _, name = line.partition(" ")
759        if name.strip() == ref:
760            return sha
761    return None
762
763
764def get_current_rev(git_root: Path) -> str | None:
765    """Get current commit rev from the git directory's HEAD."""
766    # HEAD is per-worktree, so it is read from the worktree's OWN git directory
767    git_dir = _git_dir(git_root) or git_root / ".git"
768    head_path = git_dir / "HEAD"
769    if not head_path.exists():
770        logger.warning(
771            f"{head_path} does not exist",
772            subtype="git_head",
773            location=str(head_path),
774        )
775        return None
776    head_content = head_path.read_text().strip()
777    if not head_content.startswith("ref: "):
778        # Detached HEAD (e.g. CI checkouts): .git/HEAD holds the commit SHA
779        # directly, which is exactly the rev we want.
780        return head_content
781
782    ref = head_content.split(":", 1)[1].strip()
783    common_dir = _git_common_dir(git_dir)
784    ref_path = git_dir / ref
785    if not ref_path.exists():
786        # every branch is a SHARED ref, so in a worktree it lives in the common
787        # directory; only refs/bisect and refs/worktree are per-worktree
788        ref_path = common_dir / ref
789    if ref_path.exists():
790        return ref_path.read_text().strip()
791    packed = _packed_ref(common_dir, ref)
792    if packed is not None:
793        return packed
794    logger.warning(
795        f"{ref_path} does not exist",
796        subtype="git_ref",
797        location=str(ref_path),
798    )
799    return None
800
801
802def form_https_url(
803    git_url: str, rev: str, project_path: Path, filepath: Path, lineno: int
804) -> str | None:
805    parsed_url = parse(git_url)
806    template = GIT_HOST_URL_TEMPLATE.get(parsed_url.platform)
807    if not template:
808        logger.warning(
809            f"Unsupported Git host: {parsed_url.platform}",
810            subtype="git_host",
811        )
812        return git_url
813    https_url = template.format(
814        owner=parsed_url.owner,
815        repo=parsed_url.repo,
816        rev=rev,
817        path=pathname2url(str(filepath.absolute().relative_to(project_path))),
818        lineno=str(lineno),
819    )
820    return https_url