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