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