From 69772b0196118ae820ce07b86bf048eb10a6c232 Mon Sep 17 00:00:00 2001 From: Florian Roks Date: Wed, 5 Aug 2026 08:47:35 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20NEW:=20Capture=20comment=20text=20a?= =?UTF-8?q?s=20one-line=20codelink=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `description_position` option to `OneLineCommentStyle` that lets the comment lines directly above or below a one-line marker be used as the body/content of the generated need. Defaults to `none` (disabled) to preserve existing behavior. Signed-off-by: Florian Roks --- docs/source/components/configuration.rst | 5 + docs/source/components/oneline.rst | 73 ++++++++ pyproject.toml | 11 +- src/sphinx_codelinks/analyse/analyse.py | 174 +++++++++++++++++- src/sphinx_codelinks/analyse/markup_format.py | 114 ++++++++++++ src/sphinx_codelinks/config.py | 15 +- .../sphinx_extension/directives/src_trace.py | 5 + ...ne_description-description_above_cpp].json | 21 +++ ...description_above_rust_line_comments].json | 21 +++ ...ne_description-description_below_cpp].json | 21 +++ ...description-description_below_python].json | 21 +++ ...description_below_rust_line_comments].json | 21 +++ ..._description-description_markdown_go].json | 21 +++ ...ion-description_markdown_ignored_cpp].json | 21 +++ ...escription-description_markdown_rust].json | 21 +++ ...description_mixed_slashes_above_rust].json | 21 +++ ...ption-description_mixed_slashes_rust].json | 21 +++ ...ine_description-description_none_cpp].json | 19 ++ ...ion-description_stops_at_next_marker].json | 35 ++++ ...scription-title_markdown_ignored_cpp].json | 19 ++ ...line_description-title_markdown_rust].json | 19 ++ .../data/extraction/oneline_description.yaml | 163 ++++++++++++++++ tests/test_extraction_fixtures.py | 7 +- tests/test_markup_format.py | 86 +++++++++ tests/test_oneline_parser.py | 16 ++ 25 files changed, 967 insertions(+), 4 deletions(-) create mode 100644 src/sphinx_codelinks/analyse/markup_format.py create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_above_cpp].json create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_above_rust_line_comments].json create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_below_cpp].json create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_below_python].json create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_below_rust_line_comments].json create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_markdown_go].json create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_markdown_ignored_cpp].json create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_markdown_rust].json create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_mixed_slashes_above_rust].json create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_mixed_slashes_rust].json create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_none_cpp].json create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_stops_at_next_marker].json create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-title_markdown_ignored_cpp].json create mode 100644 tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-title_markdown_rust].json create mode 100644 tests/data/extraction/oneline_description.yaml create mode 100644 tests/test_markup_format.py diff --git a/docs/source/components/configuration.rst b/docs/source/components/configuration.rst index 2b2b10a..22b8258 100644 --- a/docs/source/components/configuration.rst +++ b/docs/source/components/configuration.rst @@ -520,6 +520,11 @@ Enables the use of simplified :ref:`one-line comment patterns ` 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:** diff --git a/docs/source/components/oneline.rst b/docs/source/components/oneline.rst index 5138f46..5b0ba3a 100644 --- a/docs/source/components/oneline.rst +++ b/docs/source/components/oneline.rst @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 3e0d54c..30ea140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] @@ -60,6 +65,7 @@ testing = [ "toml>=0.10.2", "furo>=2024.5.6", "libclang>=18", + "m2r2>=0.3.4", ] docs = [ "furo>=2024.5.6", @@ -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"] @@ -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]] diff --git a/src/sphinx_codelinks/analyse/analyse.py b/src/sphinx_codelinks/analyse/analyse.py index c2c4f7a..4332c14 100644 --- a/src/sphinx_codelinks/analyse/analyse.py +++ b/src/sphinx_codelinks/analyse/analyse.py @@ -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, @@ -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 @@ -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 @@ -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 diff --git a/src/sphinx_codelinks/analyse/markup_format.py b/src/sphinx_codelinks/analyse/markup_format.py new file mode 100644 index 0000000..8e4de1b --- /dev/null +++ b/src/sphinx_codelinks/analyse/markup_format.py @@ -0,0 +1,114 @@ +"""Convert source-comment content between markup formats. + +The markup language used inside source-code comments depends on the +programming language: some ecosystems conventionally write documentation +comments in Markdown (e.g. Rust doc comments, Go doc comments), while +Sphinx-Needs renders a need's title and body as reStructuredText (RST). When +Markdown-authored text is fed verbatim into a need, its markup would be +interpreted as RST and render incorrectly. + +This module converts captured comment content (a one-line marker's title and +description) into RST *only* for languages that are statically configured to +use a non-RST comment markup. The mapping is intentionally static (not +user-configurable) and conservative: languages default to ``plain`` (no +conversion, preserving existing behaviour) unless explicitly listed in +:data:`LANGUAGE_MARKUP_FORMATS`. + +The design is extensible: adding support for another markup format is a +matter of adding a member to :class:`MarkupFormat` and a converter in +:data:`_CONVERTERS`. +""" + +from collections.abc import Callable +from enum import Enum + +from sphinx_codelinks.logger import get_logger +from sphinx_codelinks.source_discover.config import CommentType + +logger = get_logger(__name__) + + +class MarkupFormat(str, Enum): + """Markup format a language uses inside its source-code comments. + + ``plain`` is treated as already-RST (or plain text) and passed through + unchanged. Any other member is converted to RST before the content is + handed to Sphinx-Needs. + """ + + plain = "plain" + markdown = "markdown" + + +#: Static mapping of source language to the markup format used in its +#: comments. Languages absent from this mapping default to +#: :attr:`MarkupFormat.plain` (no conversion). This is the sole enablement +#: switch for comment markup conversion. +#: +#: Only languages whose comment conventions are predominantly Markdown are +#: listed here. Add or remove entries to change which languages get their +#: one-line marker content (title and description) converted. +LANGUAGE_MARKUP_FORMATS: dict[CommentType, MarkupFormat] = { + CommentType.rust: MarkupFormat.markdown, + CommentType.go: MarkupFormat.markdown, +} + + +def format_for_language(comment_type: CommentType) -> MarkupFormat: + """Return the configured comment markup format for a language. + + :param comment_type: The language whose comments are being analysed. + :return: The statically configured format, or :attr:`MarkupFormat.plain` + if the language is not listed. + """ + return LANGUAGE_MARKUP_FORMATS.get(comment_type, MarkupFormat.plain) + + +def _convert_markdown(text: str) -> str: + """Convert Markdown text to reStructuredText using ``m2r2``. + + ``m2r2`` is an optional dependency; if it is not installed the original + text is returned unchanged and a warning is emitted so the build still + succeeds (the content simply renders as-is). + + :param text: Markdown-formatted text. + :return: The equivalent reStructuredText, or ``text`` unchanged when + ``m2r2`` is unavailable. + """ + try: + from m2r2 import convert as _md_to_rst + except ImportError: + logger.warning( + "Markdown comment conversion requires the 'm2r2' package. " + "Install it (e.g. `pip install sphinx-codelinks[markdown]`) to " + "enable Markdown->RST conversion; the content will be used " + "verbatim for now.", + subtype="missing_markdown_dependency", + ) + return text + # m2r2 appends a trailing newline; strip it so content stays tight. + rst: str = _md_to_rst(text) + return rst.strip() + + +#: Registry of format-specific converters. ``plain`` has no entry and is a +#: no-op. Extend this to support additional formats. +_CONVERTERS: dict[MarkupFormat, Callable[[str], str]] = { + MarkupFormat.markdown: _convert_markdown, +} + + +def convert_markup(text: str, fmt: MarkupFormat) -> str: + """Convert comment content from ``fmt`` to reStructuredText. + + :param text: The captured comment content. + :param fmt: The markup format ``text`` is authored in. + :return: The content as reStructuredText. For :attr:`MarkupFormat.plain` + (or an empty ``text``) the input is returned unchanged. + """ + if not text: + return text + converter = _CONVERTERS.get(fmt) + if converter is None: + return text + return converter(text) diff --git a/src/sphinx_codelinks/config.py b/src/sphinx_codelinks/config.py index 7fbccde..203326c 100644 --- a/src/sphinx_codelinks/config.py +++ b/src/sphinx_codelinks/config.py @@ -177,11 +177,12 @@ class FieldConfig(TypedDict, total=False): default: str | list[str] | None -class OneLineCommentStyleType(TypedDict): +class OneLineCommentStyleType(TypedDict, total=False): start_sequence: str end_sequence: str field_split_char: str needs_fields: list[FieldConfig] + description_position: Literal["none", "above", "below"] @dataclass @@ -261,6 +262,18 @@ def field_names(cls) -> set[str]: }, ) + description_position: Literal["none", "above", "below"] = field( + default="none", + metadata={"schema": {"type": "string", "enum": ["none", "above", "below"]}}, + ) + """Where to capture the item description relative to the one-line marker. + + ``none`` disables description capture. ``above`` uses the contiguous + comment lines directly above the marker line, ``below`` uses the + contiguous comment lines directly below it. The captured text becomes + the body/content of the generated need. + """ + @classmethod def apply_needs_field_default(cls, given_fields: list[FieldConfig]) -> None: field_default = next( diff --git a/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py b/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py index 2db4316..281ff37 100644 --- a/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py +++ b/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py @@ -309,6 +309,7 @@ def render_needs( not in [ "title", "type", + "description", # rendered as the need body content ] # title and type are mandatory for add_need() } @@ -317,6 +318,10 @@ def render_needs( if remote_url_field and remote_link_name is not None: kwargs[remote_url_field] = remote_link_name + description = oneline_need.need.get("description") + if description: + kwargs["content"] = str(description) + oneline_needs: list[nodes.Node] = add_need( app=self.env.app, # The Sphinx application object state=self.state, # The docutils state object diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_above_cpp].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_above_cpp].json new file mode 100644 index 0000000..ff56e28 --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_above_cpp].json @@ -0,0 +1,21 @@ +{ + "needs": [ + { + "id": "IMPL_ABOVE", + "title": "Above Title", + "type": "impl", + "links": { + "links": [ + "REQ_2" + ] + }, + "metadata": { + "description": "This is the description above.\nSecond line of it." + }, + "line": 4 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_above_rust_line_comments].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_above_rust_line_comments].json new file mode 100644 index 0000000..dddde63 --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_above_rust_line_comments].json @@ -0,0 +1,21 @@ +{ + "needs": [ + { + "id": "IMPL_RUST_ABOVE", + "title": "Above Rust", + "type": "impl", + "links": { + "links": [ + "REQ_RS" + ] + }, + "metadata": { + "description": "Uses the FOSS CDAs to provide the version endpoints" + }, + "line": 4 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_below_cpp].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_below_cpp].json new file mode 100644 index 0000000..886f062 --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_below_cpp].json @@ -0,0 +1,21 @@ +{ + "needs": [ + { + "id": "IMPL_BELOW", + "title": "Below Title", + "type": "impl", + "links": { + "links": [ + "REQ_1" + ] + }, + "metadata": { + "description": "This is the description.\nIt spans two lines." + }, + "line": 2 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_below_python].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_below_python].json new file mode 100644 index 0000000..339d4b3 --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_below_python].json @@ -0,0 +1,21 @@ +{ + "needs": [ + { + "id": "IMPL_PY_BELOW", + "title": "Py Below", + "type": "impl", + "links": { + "links": [ + "REQ_PY" + ] + }, + "metadata": { + "description": "Description line for python." + }, + "line": 1 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_below_rust_line_comments].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_below_rust_line_comments].json new file mode 100644 index 0000000..45ab0c3 --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_below_rust_line_comments].json @@ -0,0 +1,21 @@ +{ + "needs": [ + { + "id": "IMPL_RUST_BELOW", + "title": "Below Rust", + "type": "impl", + "links": { + "links": [ + "REQ_RS" + ] + }, + "metadata": { + "description": "First description line.\nSecond description line." + }, + "line": 1 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_markdown_go].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_markdown_go].json new file mode 100644 index 0000000..1ab0aab --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_markdown_go].json @@ -0,0 +1,21 @@ +{ + "needs": [ + { + "id": "IMPL_GO_MD", + "title": "MD Go", + "type": "impl", + "links": { + "links": [ + "REQ_GO" + ] + }, + "metadata": { + "description": "Uses ``ctx`` and is **safe**." + }, + "line": 1 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_markdown_ignored_cpp].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_markdown_ignored_cpp].json new file mode 100644 index 0000000..5535bea --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_markdown_ignored_cpp].json @@ -0,0 +1,21 @@ +{ + "needs": [ + { + "id": "IMPL_CPP_PLAIN", + "title": "Plain Cpp", + "type": "impl", + "links": { + "links": [ + "REQ_CPP" + ] + }, + "metadata": { + "description": "Calls `do_thing()` and returns **fast**." + }, + "line": 2 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_markdown_rust].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_markdown_rust].json new file mode 100644 index 0000000..455945a --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_markdown_rust].json @@ -0,0 +1,21 @@ +{ + "needs": [ + { + "id": "IMPL_RUST_MD", + "title": "MD Rust", + "type": "impl", + "links": { + "links": [ + "REQ_RS" + ] + }, + "metadata": { + "description": "Calls ``do_thing()`` and returns **fast**." + }, + "line": 1 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_mixed_slashes_above_rust].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_mixed_slashes_above_rust].json new file mode 100644 index 0000000..719bc62 --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_mixed_slashes_above_rust].json @@ -0,0 +1,21 @@ +{ + "needs": [ + { + "id": "IMPL_RUST_MIXED_ABOVE", + "title": "Mixed Above", + "type": "impl", + "links": { + "links": [ + "REQ_RS" + ] + }, + "metadata": { + "description": "Doc-style above line.\nPlain above line." + }, + "line": 3 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_mixed_slashes_rust].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_mixed_slashes_rust].json new file mode 100644 index 0000000..8f909fa --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_mixed_slashes_rust].json @@ -0,0 +1,21 @@ +{ + "needs": [ + { + "id": "IMPL_RUST_MIXED", + "title": "Mixed Rust", + "type": "impl", + "links": { + "links": [ + "REQ_RS" + ] + }, + "metadata": { + "description": "Doc-style below line.\nPlain below line." + }, + "line": 1 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_none_cpp].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_none_cpp].json new file mode 100644 index 0000000..50e680c --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_none_cpp].json @@ -0,0 +1,19 @@ +{ + "needs": [ + { + "id": "IMPL_NONE", + "title": "None Title", + "type": "impl", + "links": { + "links": [ + "REQ_3" + ] + }, + "metadata": {}, + "line": 2 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_stops_at_next_marker].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_stops_at_next_marker].json new file mode 100644 index 0000000..c60b1d1 --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-description_stops_at_next_marker].json @@ -0,0 +1,35 @@ +{ + "needs": [ + { + "id": "IMPL_FIRST", + "title": "First", + "type": "impl", + "links": { + "links": [ + "REQ_A" + ] + }, + "metadata": { + "description": "Description of first." + }, + "line": 2 + }, + { + "id": "IMPL_SECOND", + "title": "Second", + "type": "impl", + "links": { + "links": [ + "REQ_B" + ] + }, + "metadata": { + "description": "Description of second." + }, + "line": 4 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-title_markdown_ignored_cpp].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-title_markdown_ignored_cpp].json new file mode 100644 index 0000000..6152da1 --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-title_markdown_ignored_cpp].json @@ -0,0 +1,19 @@ +{ + "needs": [ + { + "id": "IMPL_CPP_TITLE", + "title": "Handles `ctx` safely", + "type": "impl", + "links": { + "links": [ + "REQ_CPP" + ] + }, + "metadata": {}, + "line": 1 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-title_markdown_rust].json b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-title_markdown_rust].json new file mode 100644 index 0000000..a2a4d41 --- /dev/null +++ b/tests/__snapshots__/test_extraction_fixtures/test_extraction_fixture[oneline_description-title_markdown_rust].json @@ -0,0 +1,19 @@ +{ + "needs": [ + { + "id": "IMPL_RUST_TITLE", + "title": "Handles ``ctx`` safely", + "type": "impl", + "links": { + "links": [ + "REQ_RS" + ] + }, + "metadata": {}, + "line": 1 + } + ], + "need_refs": [], + "marked_rst": [], + "warnings": [] +} \ No newline at end of file diff --git a/tests/data/extraction/oneline_description.yaml b/tests/data/extraction/oneline_description.yaml new file mode 100644 index 0000000..ec1cb27 --- /dev/null +++ b/tests/data/extraction/oneline_description.yaml @@ -0,0 +1,163 @@ +description_below_cpp: + lang: cpp + config: + description_position: below + extract: [oneline] + source: | + /* + * @Below Title, IMPL_BELOW, impl, [REQ_1] + * This is the description. + * It spans two lines. + */ + void f() {} + +description_above_cpp: + lang: cpp + config: + description_position: above + extract: [oneline] + source: | + /* + * This is the description above. + * Second line of it. + * @Above Title, IMPL_ABOVE, impl, [REQ_2] + */ + void g() {} + +description_none_cpp: + lang: cpp + config: + description_position: none + extract: [oneline] + source: | + /* + * @None Title, IMPL_NONE, impl, [REQ_3] + * This text must be ignored. + */ + void h() {} + +description_below_python: + lang: python + config: + description_position: below + extract: [oneline] + source: | + # @Py Below, IMPL_PY_BELOW, impl, [REQ_PY] + # Description line for python. + def f(): + pass + +description_stops_at_next_marker: + lang: cpp + config: + description_position: below + extract: [oneline] + source: | + /* + * @First, IMPL_FIRST, impl, [REQ_A] + * Description of first. + * @Second, IMPL_SECOND, impl, [REQ_B] + * Description of second. + */ + void k() {} + +description_above_rust_line_comments: + lang: rust + config: + description_position: above + extract: [oneline] + source: | + /// doc comment ignored + /// + // Uses the FOSS CDAs to provide the version endpoints + // @Above Rust, IMPL_RUST_ABOVE, impl, [REQ_RS] + fn f() {} + +description_below_rust_line_comments: + lang: rust + config: + description_position: below + extract: [oneline] + source: | + // @Below Rust, IMPL_RUST_BELOW, impl, [REQ_RS] + // First description line. + // Second description line. + fn g() {} + +description_mixed_slashes_rust: + lang: rust + config: + description_position: below + extract: [oneline] + source: | + // @Mixed Rust, IMPL_RUST_MIXED, impl, [REQ_RS] + /// Doc-style below line. + // Plain below line. + fn h() {} + +description_mixed_slashes_above_rust: + lang: rust + config: + description_position: above + extract: [oneline] + source: | + /// Doc-style above line. + // Plain above line. + // @Mixed Above, IMPL_RUST_MIXED_ABOVE, impl, [REQ_RS] + fn i() {} + +# Rust comments are configured as Markdown, so inline markup in the +# description is converted to RST (e.g. `code` -> ``code``). +description_markdown_rust: + lang: rust + config: + description_position: below + extract: [oneline] + source: | + // @MD Rust, IMPL_RUST_MD, impl, [REQ_RS] + // Calls `do_thing()` and returns **fast**. + fn j() {} + +# Go comments are also configured as Markdown. +description_markdown_go: + lang: go + config: + description_position: below + extract: [oneline] + source: | + // @MD Go, IMPL_GO_MD, impl, [REQ_GO] + // Uses `ctx` and is **safe**. + func k() {} + +# C++ is NOT configured as Markdown, so backticks are preserved verbatim. +description_markdown_ignored_cpp: + lang: cpp + config: + description_position: below + extract: [oneline] + source: | + /* + * @Plain Cpp, IMPL_CPP_PLAIN, impl, [REQ_CPP] + * Calls `do_thing()` and returns **fast**. + */ + void l() {} + +# Titles are also converted for Markdown languages (inline markup only). +title_markdown_rust: + lang: rust + config: + description_position: none + extract: [oneline] + source: | + // @Handles `ctx` safely, IMPL_RUST_TITLE, impl, [REQ_RS] + fn m() {} + +# C++ title backticks are preserved verbatim (not Markdown-configured). +title_markdown_ignored_cpp: + lang: cpp + config: + description_position: none + extract: [oneline] + source: | + // @Handles `ctx` safely, IMPL_CPP_TITLE, impl, [REQ_CPP] + void n() {} diff --git a/tests/test_extraction_fixtures.py b/tests/test_extraction_fixtures.py index 1a0e560..087a434 100644 --- a/tests/test_extraction_fixtures.py +++ b/tests/test_extraction_fixtures.py @@ -53,7 +53,12 @@ def _build_oneline_style(config) -> OneLineCommentStyle: return OneLineCommentStyle() kwargs = { key: config[key] - for key in ("start_sequence", "end_sequence", "field_split_char") + for key in ( + "start_sequence", + "end_sequence", + "field_split_char", + "description_position", + ) if key in config } if "needs_fields" in config: diff --git a/tests/test_markup_format.py b/tests/test_markup_format.py new file mode 100644 index 0000000..d627ab4 --- /dev/null +++ b/tests/test_markup_format.py @@ -0,0 +1,86 @@ +"""Unit tests for comment markup format conversion. + +Covers the static language enablement map and the Markdown->RST conversion +used for one-line marker content, i.e. title and description (see +``sphinx_codelinks.analyse.markup_format``). +""" + +import pytest + +from sphinx_codelinks.analyse import markup_format as mf +from sphinx_codelinks.analyse.markup_format import ( + MarkupFormat, + convert_markup, + format_for_language, +) +from sphinx_codelinks.source_discover.config import CommentType + + +@pytest.mark.parametrize( + ("comment_type", "expected"), + [ + (CommentType.rust, MarkupFormat.markdown), + (CommentType.go, MarkupFormat.markdown), + (CommentType.cpp, MarkupFormat.plain), + (CommentType.python, MarkupFormat.plain), + (CommentType.cs, MarkupFormat.plain), + (CommentType.yaml, MarkupFormat.plain), + (CommentType.jsonc, MarkupFormat.plain), + ], +) +def test_format_for_language(comment_type: CommentType, expected: MarkupFormat): + """Only statically-enabled languages resolve to a non-plain format.""" + assert format_for_language(comment_type) == expected + + +def test_convert_plain_is_noop(): + """Plain format returns the text unchanged.""" + text = "Calls `do_thing()` and returns **fast**." + assert convert_markup(text, MarkupFormat.plain) == text + + +def test_convert_empty_is_noop(): + """Empty text is returned unchanged regardless of format.""" + assert convert_markup("", MarkupFormat.markdown) == "" + + +def test_convert_markdown_inline(): + """Markdown inline markup is converted to RST equivalents.""" + result = convert_markup( + "Calls `do_thing()` and returns **fast**.", + MarkupFormat.markdown, + ) + assert "``do_thing()``" in result + assert "**fast**" in result + # No leading/trailing whitespace remains. + assert result == result.strip() + + +def test_convert_markdown_title_inline(): + """A single-line title converts inline markup without block wrapping.""" + result = convert_markup("Calls `do_thing()` fast", MarkupFormat.markdown) + assert result == "Calls ``do_thing()`` fast" + + +def test_convert_markdown_list(): + """Markdown bullet lists become RST bullet lists.""" + result = convert_markup("- a\n- b", MarkupFormat.markdown) + assert "* a" in result + assert "* b" in result + + +def test_convert_markdown_missing_dependency(monkeypatch, capsys): + """When m2r2 is unavailable, text is returned verbatim with a warning.""" + import builtins + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "m2r2": + raise ImportError("No module named 'm2r2'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + text = "Calls `do_thing()`." + assert mf._convert_markdown(text) == text # noqa: SLF001 diff --git a/tests/test_oneline_parser.py b/tests/test_oneline_parser.py index d079b14..f089239 100644 --- a/tests/test_oneline_parser.py +++ b/tests/test_oneline_parser.py @@ -522,6 +522,22 @@ def test_oneline_parser_default_config_negative( "Missing required fields: ['title', 'type']", ], ), + ( + OneLineCommentStyle( + start_sequence="[[", + end_sequence="]]", + field_split_char=",", + description_position="sideways", # invalid enum value + needs_fields=[ + {"name": "title"}, + {"name": "id"}, + {"name": "type", "default": "impl"}, + ], + ), + [ + "Schema validation error in field 'description_position': 'sideways' is not one of ['none', 'above', 'below']" + ], + ), ], ) def test_oneline_schema_validator_negative(oneline_config, result):