diff --git a/docs/source/requirements.rst b/docs/source/requirements.rst index 2be1184..a7822df 100644 --- a/docs/source/requirements.rst +++ b/docs/source/requirements.rst @@ -215,7 +215,7 @@ the requirement falls in, the type of requirement, and whether ' docformatter_10.4.1', ' Shall ignore docstrings in other styles when using Sphinx style.', ' Style', ' Shall', ' Yes' ' docformatter_10.4.2', ' Shall wrap Sphinx-style parameter descriptions that exceed wrap length when using Sphinx style.', ' Shall', ' Yes' ' docformatter_10.4.3', ' Shall ensure one blank space between a field name and field body.', ' Style', ' Shall', ' Yes [PR #220]' - ' docformatter_10.4.3.1', ' Shall NOT add a blank space after a field name when the field body is a link.', ' Style', ' Shall', ' Yes [PR #220]' + ' docformatter_10.4.3.1', ' Shall add a blank space after a field name when the field body is a link, as required by reST field-marker syntax.', ' Style', ' Shall', ' Yes [Issue #279]' ' docformatter_10.4.3.2', ' Shall NOT add a blank space after a field name when there is no field body.', ' Style', ' Shall', ' Yes [PR #220]' ' docformatter_10.5', ' Should format docstrings compatible with black.', ' Style', ' Should', ' Yes [PR #192]' ' docformatter_10.5.1', ' Should wrap summaries at 88 characters by default in black mode.', ' Style', ' Should', ' Yes' diff --git a/src/docformatter/constants.py b/src/docformatter/constants.py index 5b95a8e..c2554a7 100644 --- a/src/docformatter/constants.py +++ b/src/docformatter/constants.py @@ -139,6 +139,14 @@ ) """Regular expression to use for finding reST section headers.""" +# Field names are extensible. Match the reST marker separately so custom fields +# do not need to be added to Sphinx's legacy, permissive pattern below. +# https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#field-lists +REST_FIELD_REGEX = ( + r":(?![: \t])(?:[^:\\\r\n]|\\.|:(?![ `\t\r\n]|$))*(?{REST_FIELD_REGEX})|{SPHINX_REGEX}", + text, + flags=re.MULTILINE, + ): + _marker_group = "field" if _field.group("field") is not None else 0 + _field_idx.append(_field.span(_marker_group)) _wrap_parameters = True return _field_idx, _wrap_parameters diff --git a/src/docformatter/wrappers/fields.py b/src/docformatter/wrappers/fields.py index a003f9d..c0e7218 100644 --- a/src/docformatter/wrappers/fields.py +++ b/src/docformatter/wrappers/fields.py @@ -125,8 +125,8 @@ def _do_join_field_body(text: str, field_idx: list[tuple[int, int]], idx: int) - [_line.strip() for _line in _field_body.splitlines()] ).strip() - # Add a space before the field body unless the field body is a link. - if not _field_body.startswith("`") and _field_body: + # Field markers require whitespace before any body, including inline links. + if _field_body: _field_body = f" {_field_body}" # Is there a blank line between field lists? Keep it if so. diff --git a/tests/formatter/test_custom_rst_fields.py b/tests/formatter/test_custom_rst_fields.py new file mode 100644 index 0000000..97d2ce1 --- /dev/null +++ b/tests/formatter/test_custom_rst_fields.py @@ -0,0 +1,113 @@ +# pylint: skip-file +# type: ignore +# +# tests.formatter.test_custom_rst_fields.py is part of the docformatter project +# +# Copyright (C) 2012-2023 Steven Myint +# Copyright (C) 2023-2025 Doyle "weibullguy" Rowland +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Regression coverage for custom reStructuredText fields (issue #279).""" + +# Standard Library Imports +import sys + +# Third Party Imports +import pytest + +# docformatter Package Imports +from docformatter.format import Formatter +from docformatter.patterns.fields import do_find_field_lists + + +@pytest.mark.parametrize( + "field", + [ + "calls", + "custom-name", + "param dict[str, int] value", + "custom:name", + r"escaped\: name", + "字段", + ], +) +def test_custom_field_markers(field): + text = "Prose mentions :func:`a_role`.\n :" + field + ": body\n" + indices, wrap = do_find_field_lists(text, "sphinx") + assert wrap + assert [text[start:end] for start, end in indices] == [":" + field + ":"] + + +@pytest.mark.parametrize( + "text", + [ + "Prose :calls: text", + ":class:`Thing`", + ":: text", + ": calls: text", + ":name : text", + ":calls:body", + ], +) +def test_prose_and_roles_are_not_field_markers(text): + assert do_find_field_lists(text, "sphinx")[0] == [] + + +@pytest.mark.parametrize("args", [[""], ["--black", ""]]) +@pytest.mark.parametrize( + "field", ["calls", "custom-name", "param dict[str, int] value"] +) +def test_custom_field_wrapping_is_indented_and_idempotent(test_args, args, field): + source = ( + "def f():\n" + ' """Create a file.\n\n' + " :" + field + ": `PUT /repos/{owner}/{repo}/contents/{path} " + "`_\n" + " :param path: string, (required), path of the file in the repository\n" + " :param message: string, (required), commit message\n" + ' """\n' + ) + formatter = Formatter(test_args, sys.stderr, sys.stdin, sys.stdout) + result = formatter._do_format_code(source) + lines = result.splitlines() + start = next( + i for i, line in enumerate(lines) if line.startswith(" :" + field + ":") + ) + end = next(i for i, line in enumerate(lines) if line.startswith(" :param path:")) + assert lines[start].startswith(" :" + field + ": ") + assert end > start + 1 + assert all(line.startswith(" ") for line in lines[start + 1 : end]) + assert formatter._do_format_code(result) == result + + +@pytest.mark.parametrize("args", [[""], ["--black", ""]]) +@pytest.mark.parametrize( + "body", + [ + " - GET first\n - POST second\n", + " 1. GET first\n 2. POST second\n", + " >>> print(1)\n 1\n", + ], +) +def test_complex_custom_field_body_is_preserved(test_args, args, body): + source = '"""Summary.\n\n:calls: Available operations.\n\n' + body + '"""\n' + formatter = Formatter(test_args, sys.stderr, sys.stdin, sys.stdout) + assert formatter._do_format_code(source) == source