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