1import configparser
2from collections.abc import ByteString, Callable
3from pathlib import Path
4from urllib.request import pathname2url
5
6from giturlparse import parse
7from tree_sitter import Language, Parser, Point, 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 # @Rust Scope Node Types, IMPL_RUST_2, impl, [FE_RUST]
31 CommentType.rust: {
32 "function_item",
33 "struct_item",
34 "enum_item",
35 "impl_item",
36 "trait_item",
37 "mod_item",
38 },
[docs] 39 # @Go Scope Node Types, IMPL_GO_4, impl, [FE_GO]
40 CommentType.go: {
41 "function_declaration",
42 "method_declaration",
43 "type_declaration",
44 "type_spec",
45 },
[docs] 46 # @Bash Scope Node Types, IMPL_BASH_2, impl, [FE_BASH]
47 CommentType.bash: {"function_definition"},
48}
49
50logger = get_logger(__name__)
51
52GIT_HOST_URL_TEMPLATE = {
53 "github": "https://github.com/{owner}/{repo}/blob/{rev}/{path}#L{lineno}",
54 "gitlab": "https://gitlab.com/{owner}/{repo}/-/blob/{rev}/{path}#L{lineno}",
55}
56
57PYTHON_QUERY = """
58 ; Match comments
59 (comment) @comment
60
61 ; Match docstrings inside modules, functions, or classes
62 (module (expression_statement (string)) @comment)
63 (function_definition (block (expression_statement (string)) @comment))
64 (class_definition (block (expression_statement (string)) @comment))
65 """
66CPP_QUERY = """(comment) @comment"""
67C_SHARP_QUERY = """(comment) @comment"""
68YAML_QUERY = """(comment) @comment"""
69RUST_QUERY = """
70 (line_comment) @comment
71 (block_comment) @comment
72"""
[docs] 73# @Go comment query for tree-sitter, IMPL_GO_3, impl, [FE_GO]
74GO_QUERY = """
75 (comment) @comment
76"""
77JSONC_QUERY = """(comment) @comment"""
[docs] 78# @Bash comment query for tree-sitter, IMPL_BASH_3, impl, [FE_BASH]
79BASH_QUERY = """(comment) @comment"""
80
81# JSON value node types that can be associated with a comment.
82JSON_STRUCTURE_TYPES = {
83 "pair",
84 "object",
85 "array",
86 "string",
87 "number",
88 "true",
89 "false",
90 "null",
91}
92
93
94def is_text_file(filepath: Path, sample_size: int = 2048) -> bool:
95 """Return True if file is likely text, False if binary."""
96 try:
97 with filepath.open("rb") as f:
98 chunk = f.read(sample_size)
99 # Quick binary heuristic: null byte present
100 if b"\x00" in chunk:
101 return False
102 # Try UTF-8 decode on the sample
103 chunk.decode("utf-8")
104 return True
105 except UnicodeDecodeError:
106 return False
107
108
[docs]109# @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]
110def init_tree_sitter(comment_type: CommentType) -> tuple[Parser, Query]:
111 if comment_type == CommentType.cpp:
112 import tree_sitter_cpp
113
114 parsed_language = Language(tree_sitter_cpp.language())
115 query = Query(parsed_language, CPP_QUERY)
116 elif comment_type == CommentType.python:
117 import tree_sitter_python
118
119 parsed_language = Language(tree_sitter_python.language())
120 query = Query(parsed_language, PYTHON_QUERY)
121 elif comment_type == CommentType.cs:
122 import tree_sitter_c_sharp
123
124 parsed_language = Language(tree_sitter_c_sharp.language())
125 query = Query(parsed_language, C_SHARP_QUERY)
126 elif comment_type == CommentType.yaml:
127 import tree_sitter_yaml
128
129 parsed_language = Language(tree_sitter_yaml.language())
130 query = Query(parsed_language, YAML_QUERY)
131 elif comment_type == CommentType.rust:
132 import tree_sitter_rust
133
134 parsed_language = Language(tree_sitter_rust.language())
135 query = Query(parsed_language, RUST_QUERY)
136 elif comment_type == CommentType.go:
137 import tree_sitter_go
138
139 parsed_language = Language(tree_sitter_go.language())
140 query = Query(parsed_language, GO_QUERY)
141 elif comment_type == CommentType.jsonc:
142 import tree_sitter_json
143
144 parsed_language = Language(tree_sitter_json.language())
145 query = Query(parsed_language, JSONC_QUERY)
146 elif comment_type == CommentType.bash:
147 import tree_sitter_bash
148
149 parsed_language = Language(tree_sitter_bash.language())
150 query = Query(parsed_language, BASH_QUERY)
151 else:
152 raise ValueError(f"Unsupported comment style: {comment_type}")
153 parser = Parser(parsed_language)
154 return parser, query
155
156
157def wrap_read_callable_point(
158 src_string: ByteString,
159) -> Callable[[int, Point], ByteString]:
160 def read_callable_byte_offset(byte_offset: int, _: Point) -> ByteString:
161 return src_string[byte_offset : byte_offset + 1]
162
163 return read_callable_byte_offset
164
165
[docs]166# @Comment extraction from source code using tree-sitter, IMPL_EXTR_1, impl, [FE_DEF]
167def extract_comments(
168 src_string: ByteString, parser: Parser, query: Query
169) -> list[TreeSitterNode] | None:
170 """Get all comments from source files by tree-sitter."""
171 read_point_fn = wrap_read_callable_point(src_string)
172 tree = parser.parse(read_point_fn)
173 query_cursor = QueryCursor(query)
174 captures: dict[str, list[TreeSitterNode]] = query_cursor.captures(tree.root_node)
175
176 return captures.get("comment")
177
178
179def find_enclosing_scope(
180 node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
181) -> TreeSitterNode | None:
182 """Find the enclosing scope of a comment."""
183 scope_types = SCOPE_NODE_TYPES.get(comment_type, SCOPE_NODE_TYPES[CommentType.cpp])
184 current: TreeSitterNode = node
185 while current:
186 if current.type in scope_types:
187 return current
188 current: TreeSitterNode | None = current.parent # required for node traversal
189 return None
190
191
192def find_next_scope(
193 node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
194) -> TreeSitterNode | None:
195 """Find the next scope of a comment."""
196 scope_types = SCOPE_NODE_TYPES.get(comment_type, SCOPE_NODE_TYPES[CommentType.cpp])
197 current: TreeSitterNode = node
198 while current:
199 if current.type in scope_types:
200 return current
201 current: TreeSitterNode | None = (
202 current.next_named_sibling
203 ) # required for node traversal
204 if current and current.type == "block":
205 for child in current.named_children:
206 if child.type in scope_types:
207 return child
208 return None
209
210
211def _find_yaml_structure_in_block_node(
212 block_node: TreeSitterNode,
213) -> TreeSitterNode | None:
214 """Find YAML structure elements within a block_node."""
215 for grandchild in block_node.named_children:
216 if grandchild.type == "block_mapping":
217 for ggchild in grandchild.named_children:
218 if ggchild.type == "block_mapping_pair":
219 return ggchild
220 elif grandchild.type == "block_sequence":
221 for ggchild in grandchild.named_children:
222 if ggchild.type == "block_sequence_item":
223 return ggchild
224 return None
225
226
227def find_yaml_next_structure(node: TreeSitterNode) -> TreeSitterNode | None:
228 """Find the next YAML structure element after the comment node."""
229 current = node.next_named_sibling
230 while current:
231 if current.type in {
232 "block_mapping_pair",
233 "block_sequence_item",
234 "flow_mapping",
235 "flow_sequence",
236 }:
237 return current
238 if current.type == "document":
239 for child in current.named_children:
240 if child.type == "block_node":
241 result = _find_yaml_structure_in_block_node(child)
242 if result:
243 return result
244 if current.type == "block_node":
245 result = _find_yaml_structure_in_block_node(current)
246 if result:
247 return result
248 current = current.next_named_sibling
249 return None
250
251
252def find_prev_sibling_on_same_row(node: TreeSitterNode) -> TreeSitterNode | None:
253 """Find a previous named sibling that is on the same row as the comment.
254
255 Grammar-agnostic: used to detect inline comments in both YAML and JSONC.
256 """
257 comment_row = node.start_point.row
258 current = node.prev_named_sibling
259
260 while current:
261 # Check if this sibling ends on the same row as the comment starts
262 # This indicates it's an inline comment
263 if current.end_point.row == comment_row:
264 return current
265 # If we find a sibling that ends before the comment row, we can stop
266 # as we won't find any siblings on the same row going backwards
267 if current.end_point.row < comment_row:
268 break
269 current = current.prev_named_sibling
270
271 return None
272
273
274def find_yaml_associated_structure(node: TreeSitterNode) -> TreeSitterNode | None:
275 """Find the YAML structure (key-value pair, list item, etc.) associated with a comment."""
276 # First, check if this is an inline comment by looking for a previous sibling on the same row
277 prev_sibling_same_row = find_prev_sibling_on_same_row(node)
278 if prev_sibling_same_row:
279 return prev_sibling_same_row
280
281 # If no previous sibling on same row, try to find the next named sibling (structure after the comment)
282 structure = find_yaml_next_structure(node)
283 if structure:
284 return structure
285
286 # If no next sibling found, traverse up to find parent structure
287 parent = node.parent
288 while parent:
289 if parent.type in {"block_mapping_pair", "block_sequence_item"}:
290 return parent
291 parent = parent.parent
292
293 return None
294
295
[docs]296# @JSONC comment-to-structure association, IMPL_JSONC_2, impl, [FE_JSONC]
297def find_jsonc_associated_structure(node: TreeSitterNode) -> TreeSitterNode | None:
298 """Find the JSON structure (key/value pair, value, list item) for a comment.
299
300 JSON is data rather than code, so association follows the same intent as YAML:
301 an inline comment belongs to the value on its row, a leading comment belongs to
302 the following structure, otherwise it belongs to the enclosing structure.
303 """
304 # Inline comment: a value/pair on the same row, before the comment
305 prev_sibling_same_row = find_prev_sibling_on_same_row(node)
306 if prev_sibling_same_row:
307 return prev_sibling_same_row
308
309 # Leading comment: the next structure following the comment
310 current = node.next_named_sibling
311 while current:
312 if current.type in JSON_STRUCTURE_TYPES:
313 return current
314 current = current.next_named_sibling
315
316 # Otherwise: the enclosing structure
317 parent = node.parent
318 while parent:
319 if parent.type in {"pair", "object", "array"}:
320 return parent
321 parent = parent.parent
322
323 return None
324
325
326def find_associated_scope(
327 node: TreeSitterNode, comment_type: CommentType = CommentType.cpp
328) -> TreeSitterNode | None:
329 """Find the associated scope of a comment."""
330 if comment_type == CommentType.yaml:
331 # YAML uses different structure association logic
332 return find_yaml_associated_structure(node)
333
334 if comment_type == CommentType.jsonc:
335 # JSONC uses data-aware structure association logic
336 return find_jsonc_associated_structure(node)
337
338 if node.type == CommentCategory.docstring:
339 # Only for python's docstring
340 return find_enclosing_scope(node, comment_type)
341 # General comments regardless of comment types
342 associated_scope = find_next_scope(node, comment_type)
343 if not associated_scope:
344 associated_scope = find_enclosing_scope(node, comment_type)
345 return associated_scope
346
347
348def _git_dir(path: Path) -> Path | None:
349 """The git directory for a checkout rooted at ``path``, or None if it is not one.
350
351 In a plain checkout ``.git`` is a directory. In a LINKED WORKTREE it is a file
352 holding ``gitdir: <path>`` -- git's documented gitfile format -- and the real
353 directory is elsewhere, under the main repository's ``.git/worktrees/<name>``.
354 Treating the file as "not a repository" is what made every worktree of this
355 repository look unversioned (useblocks/sphinx-codelinks#106).
356 """
357 dot_git = path / ".git"
358 if dot_git.is_dir():
359 return dot_git
360 if dot_git.is_file():
361 content = dot_git.read_text(encoding="utf-8", errors="replace").strip()
362 if content.startswith("gitdir:"):
363 target = Path(content.split(":", 1)[1].strip())
364 if not target.is_absolute():
365 target = (path / target).resolve()
366 if target.is_dir():
367 return target
368 return None
369
370
371def _git_common_dir(git_dir: Path) -> Path:
372 """The repository-wide git directory that ``git_dir`` belongs to.
373
374 A linked worktree's git directory carries a ``commondir`` file naming the main
375 repository's, and that is where ``config`` and every shared ref live -- only
376 per-worktree state (``HEAD``, ``refs/bisect``, ``refs/worktree``) is local. A plain
377 checkout has no ``commondir`` and the two are the same directory.
378 """
379 commondir = git_dir / "commondir"
380 if not commondir.is_file():
381 return git_dir
382 target = Path(commondir.read_text(encoding="utf-8").strip())
383 if not target.is_absolute():
384 target = (git_dir / target).resolve()
385 return target
386
387
388def find_git_root(src_dir: Path) -> Path | None:
389 """The git root :func:`locate_git_root` finds, without its warning when none."""
390 current = src_dir.resolve()
391 parents = list(current.parents)
392 parents.append(current)
393 for parent in parents:
394 if _git_dir(parent) is not None:
395 return parent
396 return None
397
398
399def locate_git_root(src_dir: Path) -> Path | None:
400 """Traverse upwards to find git root."""
401 found = find_git_root(src_dir)
402 if found is not None:
403 return found
404 logger.warning(
405 f"git root is not found in the parent of {src_dir}",
406 subtype="git_root",
407 location=str(src_dir),
408 )
409 return None
410
411
412def get_remote_url(git_root: Path, remote_name: str = "origin") -> str | None:
413 """Get remote url from the git directory's config."""
414 git_dir = _git_dir(git_root)
415 # a worktree's remotes are the main repository's, so `config` is in the common dir
416 config_path = (
417 _git_common_dir(git_dir) if git_dir is not None else git_root / ".git"
418 ) / "config"
419 if not config_path.exists():
420 logger.warning(
421 f"{config_path} does not exist",
422 subtype="git_config",
423 location=str(config_path),
424 )
425 return None
426
427 config = configparser.ConfigParser(allow_no_value=True, strict=False)
428 config.read(config_path)
429 section = f'remote "{remote_name}"'
430 if section in config and "url" in config[section]:
431 url: str = config[section]["url"]
432 return url
433 logger.warning(
434 f"remote-url is not found in {config_path}",
435 subtype="git_remote",
436 location=str(config_path),
437 )
438 return None
439
440
441def _packed_ref(common_dir: Path, ref: str) -> str | None:
442 """The sha ``packed-refs`` records for ``ref``, or None.
443
444 A ref has no file of its own once ``git gc`` (or ``git pack-refs``) has run, which
445 happens unattended on any long-lived checkout -- so a loose-file lookup alone reports
446 "no rev" for a perfectly ordinary repository. The file is one ``<sha> <refname>`` per
447 line; ``^<sha>`` lines are the peeled targets of annotated tags and are skipped, as
448 is the leading ``#`` header.
449 """
450 packed = common_dir / "packed-refs"
451 if not packed.is_file():
452 return None
453 for line in packed.read_text(encoding="utf-8", errors="replace").splitlines():
454 if not line or line.startswith(("#", "^")):
455 continue
456 sha, _, name = line.partition(" ")
457 if name.strip() == ref:
458 return sha
459 return None
460
461
462def get_current_rev(git_root: Path) -> str | None:
463 """Get current commit rev from the git directory's HEAD."""
464 # HEAD is per-worktree, so it is read from the worktree's OWN git directory
465 git_dir = _git_dir(git_root) or git_root / ".git"
466 head_path = git_dir / "HEAD"
467 if not head_path.exists():
468 logger.warning(
469 f"{head_path} does not exist",
470 subtype="git_head",
471 location=str(head_path),
472 )
473 return None
474 head_content = head_path.read_text().strip()
475 if not head_content.startswith("ref: "):
476 # Detached HEAD (e.g. CI checkouts): .git/HEAD holds the commit SHA
477 # directly, which is exactly the rev we want.
478 return head_content
479
480 ref = head_content.split(":", 1)[1].strip()
481 common_dir = _git_common_dir(git_dir)
482 ref_path = git_dir / ref
483 if not ref_path.exists():
484 # every branch is a SHARED ref, so in a worktree it lives in the common
485 # directory; only refs/bisect and refs/worktree are per-worktree
486 ref_path = common_dir / ref
487 if ref_path.exists():
488 return ref_path.read_text().strip()
489 packed = _packed_ref(common_dir, ref)
490 if packed is not None:
491 return packed
492 logger.warning(
493 f"{ref_path} does not exist",
494 subtype="git_ref",
495 location=str(ref_path),
496 )
497 return None
498
499
500def form_https_url(
501 git_url: str, rev: str, project_path: Path, filepath: Path, lineno: int
502) -> str | None:
503 parsed_url = parse(git_url)
504 template = GIT_HOST_URL_TEMPLATE.get(parsed_url.platform)
505 if not template:
506 logger.warning(
507 f"Unsupported Git host: {parsed_url.platform}",
508 subtype="git_host",
509 )
510 return git_url
511 https_url = template.format(
512 owner=parsed_url.owner,
513 repo=parsed_url.repo,
514 rev=rev,
515 path=pathname2url(str(filepath.absolute().relative_to(project_path))),
516 lineno=str(lineno),
517 )
518 return https_url