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