Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/source/components/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,11 @@ Enables the use of simplified :ref:`one-line comment patterns <oneline>` to repr
- ``end_sequence`` - Character(s) that end a one-line comment pattern (typically line ending)
- ``field_split_char`` - Character used to separate fields within the comment
- ``needs_fields`` - List of field definitions for extracting need information
- ``description_position`` - Where to capture the item description relative to the
marker line. One of ``none`` (default, disabled), ``above`` or ``below``. When
set, the contiguous comment lines directly above/below the marker line are used
as the body/content of the generated need. Collection stops at the next marker
line, a blank line, or the comment boundary.

**Example usage:**

Expand Down
73 changes: 73 additions & 0 deletions docs/source/components/oneline.rst
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,76 @@ To have a backslash ``\`` as a literal in the value, use ``\\`` as shown in the
:links: [SPEC,_1]

.. caution:: Field values can never contain any newline characters ``\r`` or ``\n``.

Description
~~~~~~~~~~~

By default, only the text inside the one-line marker is used. Set
``description_position`` to ``above`` or ``below`` to additionally use the
surrounding comment text as the ``need item``'s description (body/content).

The description is the contiguous block of comment lines directly above or
below the marker line. Collection stops at the next marker line, a blank line,
or the boundary of the comment. Comment decoration (e.g. ``*``, ``//``, ``#``)
and surrounding whitespace are stripped from each line.

.. tabs::

.. code-tab:: toml

[codelinks.projects.my_project.analyse.oneline_comment_style]
description_position = "below"

.. code-tab:: c

/*
* @Function Bar, IMPL_4, impl, [SPEC_1]
* This function does the thing.
* It has a multi-line description.
*/

.. code-tab:: rst

.. impl:: Function Bar
:id: IMPL_4
:links: SPEC_1

This function does the thing.
It has a multi-line description.

With ``description_position = "above"`` the description block is taken from the
comment lines immediately preceding the marker line instead.

.. note:: Setting ``description_position = "none"`` (the default) disables
description capture, preserving the original behavior.

Comment markup conversion
~~~~~~~~~~~~~~~~~~~~~~~~~~~

The markup language used inside source-code comments depends on the
programming language. Sphinx-Needs renders a need's title and body as
reStructuredText (RST), so content authored in another markup (e.g. Markdown)
would otherwise be interpreted as RST and render incorrectly.

For languages whose comments are conventionally written in Markdown, the
captured one-line marker content — both the **title** and the **description** —
is automatically converted from Markdown to RST. This is controlled by a
*static* language mapping in ``sphinx_codelinks.analyse.markup_format``
(``LANGUAGE_MARKUP_FORMATS``) and is **not** user-configurable. Languages not
listed there default to ``plain`` (no conversion), preserving the original
behavior.

Currently, ``rust`` and ``go`` are mapped to Markdown. For these languages a
description such as ``Calls `do_thing()` and returns **fast**.`` is converted to
the RST equivalent ``Calls ``do_thing()`` and returns **fast**.`` (the same
inline conversion applies to titles).

Markdown conversion requires the optional ``m2r2`` dependency. Install it with:

.. code-block:: bash

pip install sphinx-codelinks[markdown]

If ``m2r2`` is not installed, the content is used verbatim and a warning is
emitted. The mechanism is extensible: additional markup formats can be added by
extending ``MarkupFormat`` and the converter registry in that module.
11 changes: 10 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ dependencies = [
# 17 and 18 (the engine only uses get_all_skipped_ranges + comment tokenization,
# stable since long before 16), so support older toolchains too. No upper bound.
libclang = ["libclang>=16"]
# Enables Markdown->RST conversion of one-line marker descriptions for
# languages whose comments are authored in Markdown (see
# sphinx_codelinks.analyse.markup_format). Optional so projects that do
# not use Markdown comments avoid the extra dependency.
markdown = ["m2r2>=0.3.4"]

[build-system]
requires = ["flit_core >=3.4,<4"]
Expand All @@ -60,6 +65,7 @@ testing = [
"toml>=0.10.2",
"furo>=2024.5.6",
"libclang>=18",
"m2r2>=0.3.4",
]
docs = [
"furo>=2024.5.6",
Expand Down Expand Up @@ -131,6 +137,9 @@ force-sort-within-sections = true
"src/sphinx_codelinks/cmd.py" = [
"PLC0415", # import on top - only import relevant modules by use cases
]
"src/sphinx_codelinks/analyse/markup_format.py" = [
"PLC0415", # import-outside-top-level - lazy import of optional 'm2r2' dependency
]

[tool.mypy]
exclude = ["tests/", "dist/", "docs/_build/", "docs/conf.py"]
Expand All @@ -156,7 +165,7 @@ mypy_path = "typings"

[[tool.mypy.overrides]]
# clang.cindex ships no type stubs / py.typed; the libclang engine wraps it.
module = ["licensing.*", "tomlkit.*", "clang.*"]
module = ["licensing.*", "tomlkit.*", "clang.*", "m2r2.*"]
ignore_missing_imports = true

[[tool.mypy.overrides]]
Expand Down
174 changes: 173 additions & 1 deletion src/sphinx_codelinks/analyse/analyse.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from tree_sitter import Node as TreeSitterNode

from sphinx_codelinks.analyse import utils
from sphinx_codelinks.analyse.markup_format import convert_markup, format_for_language
from sphinx_codelinks.analyse.models import (
MarkedContentType,
MarkedRst,
Expand Down Expand Up @@ -36,6 +37,145 @@ def _count(n: int, noun: str) -> str:
return f"{n} {noun}" if n == 1 else f"{n} {noun}s"


# Leading comment decoration sequences stripped from description lines.
_DESCRIPTION_LEADING_SEQUENCES = ["///", "//", "*", "#", "--"]


def _clean_description_line(line: str) -> str:
"""Strip comment decoration and surrounding whitespace from a line."""
stripped = line.strip()
# Remove block-comment delimiters that may appear on the opening/closing
# lines of a ``/* ... */`` comment.
if stripped.startswith("/*"):
stripped = stripped[len("/*") :].strip()
if stripped.endswith("*/"):
stripped = stripped[: -len("*/")].strip()
for sequence in _DESCRIPTION_LEADING_SEQUENCES:
if stripped.startswith(sequence):
stripped = stripped[len(sequence) :].strip()
break
return stripped


def extract_description(
lines: list[str],
is_marker_line: list[bool],
marker_idx: int,
position: str,
) -> str:
"""Extract the description block adjacent to a one-line marker.

The description is the contiguous block of non-marker comment lines
directly ``above`` or ``below`` the marker line. Collection stops at the
next marker line, at a blank line, or at the comment boundary. Comment
decoration and surrounding whitespace are stripped from each line.

:param lines: All lines of the comment (with trailing newlines).
:param is_marker_line: Per-line flag indicating a marker line.
:param marker_idx: Index of the current marker line in ``lines``.
:param position: One of ``none``, ``above`` or ``below``.
:return: The joined description text, or an empty string.
"""
if position not in ("above", "below"):
return ""

collected: list[str] = []
if position == "below":
idx = marker_idx + 1
step = 1
else:
idx = marker_idx - 1
step = -1

while 0 <= idx < len(lines):
if is_marker_line[idx]:
break
cleaned = _clean_description_line(lines[idx])
if not cleaned:
# stop at the first blank line to keep descriptions tightly scoped
break
collected.append(cleaned)
idx += step

if position == "above":
collected.reverse()

return UNIX_NEWLINE.join(collected)


def _is_comment_node(node: TreeSitterNode | None) -> bool:
"""Return True if the tree-sitter node represents a comment."""
if node is None:
return False
return "comment" in node.type


def extract_description_from_siblings(
node: TreeSitterNode,
position: str,
oneline_comment_style: OneLineCommentStyle,
) -> str:
"""Extract a description from adjacent sibling comment nodes.

Many languages (e.g. Rust, C++, Go) model consecutive ``//`` line
comments as separate sibling nodes rather than a single multi-line
comment. ``extract_description`` only sees the text of the current
comment node, so it cannot reach those neighbors. This helper walks the
immediately adjacent sibling comment nodes directly ``above`` or
``below`` the marker node and collects their cleaned text.

Collection stops when the next sibling is not a comment, is not on the
immediately adjacent row, is itself a marker line, or is blank.

:param node: The tree-sitter comment node containing the marker.
:param position: One of ``none``, ``above`` or ``below``.
:param oneline_comment_style: Style used to detect neighboring markers.
:return: The joined description text, or an empty string.
"""
if position not in ("above", "below"):
return ""

collected: list[str] = []
if position == "below":
current = node.next_named_sibling
expected_row = node.start_point.row + 1
else:
current = node.prev_named_sibling
expected_row = node.start_point.row - 1

while current is not None and _is_comment_node(current):
text = current.text.decode("utf-8") if current.text else ""
# A single-line comment node may still report ``end_point.row`` one
# greater than ``start_point.row`` because it absorbs the trailing
# newline (e.g. Rust ``///`` doc comments). Derive the visible span
# from the text itself so such nodes are not treated as multi-line.
visible_rows = len(text.rstrip(UNIX_NEWLINE).splitlines()) or 1
# Only single-line neighbors on the immediately adjacent row belong
# to the description block; anything else ends it.
if visible_rows != 1:
break
if current.start_point.row != expected_row:
break
# A neighboring marker line terminates the description block.
if oneline_parser(text, oneline_comment_style) is not None:
break
cleaned = _clean_description_line(text)
if not cleaned:
break
collected.append(cleaned)
if position == "below":
current = current.next_named_sibling
expected_row += 1
else:
current = current.prev_named_sibling
expected_row -= 1

if position == "above":
collected.reverse()

return UNIX_NEWLINE.join(collected)


class AnalyseWarningType(TypedDict):
file_path: str
lineno: int
Expand Down Expand Up @@ -315,7 +455,14 @@ def extract_oneline_need(
# single line comment has no newline char in the extracted comment
lines[0] = f"{lines[0]}{UNIX_NEWLINE}"

for line in lines:
# Pre-classify each line as a marker line (has a valid or invalid
# marker) or a plain text line. This is needed to bound description
# blocks by neighboring markers.
is_marker_line = [
oneline_parser(line, oneline_comment_style) is not None for line in lines
]

for line_idx, line in enumerate(lines):
resolved = oneline_parser(line, oneline_comment_style)
if not resolved:
row_offset += 1
Expand All @@ -335,6 +482,31 @@ def extract_oneline_need(
self.oneline_warnings.append(warning)
row_offset += 1
continue
# The markup language of comment content depends on the source
# language (e.g. Markdown for Rust/Go). Convert the title and
# description to RST when configured, so Sphinx-Needs renders them
# correctly regardless of the file's comment convention.
markup_format = format_for_language(self.analyse_config.comment_type)
title = resolved.get("title")
if isinstance(title, str) and title:
resolved["title"] = convert_markup(title, markup_format)
description = extract_description(
lines,
is_marker_line,
line_idx,
oneline_comment_style.description_position,
)
if not description:
# Consecutive single-line comments (e.g. Rust/C++ ``//``)
# are separate sibling nodes, so the description may live in
# an adjacent comment node rather than within this one.
description = extract_description_from_siblings(
src_comment.node,
oneline_comment_style.description_position,
oneline_comment_style,
)
if description:
resolved["description"] = convert_markup(description, markup_format)
yield resolved, row_offset
row_offset += 1

Expand Down
Loading
Loading