From b18e2e77065f4d47410dda352badd3e59e13534d Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Mon, 5 Oct 2026 04:05:54 -0700 Subject: [PATCH] Preserve single-line code spans in fallback docstrings --- pylsp/_utils.py | 43 +++++++++++++++++++++++++++++++++------- test/test_utils.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/pylsp/_utils.py b/pylsp/_utils.py index 0ce0e8cb..da9cea35 100644 --- a/pylsp/_utils.py +++ b/pylsp/_utils.py @@ -210,14 +210,43 @@ def escape_plain_text(contents: str) -> str: def escape_markdown(contents: str) -> str: + """Escape Markdown syntax outside single-line code spans. + + Keep the previous escaping behavior when paired backticks cross a line. """ - Format plain text to display nicely in Markdown environment. - """ - # escape markdown syntax - contents = re.sub(r"([\\*_#[\]])", r"\\\1", contents) - # preserve white space characters - contents = escape_plain_text(contents) - return contents + + def escape_text(text): + return escape_plain_text(re.sub(r"([\\*_#[\]])", r"\\\1", text)) + + runs = list(re.finditer(r"`+", contents)) + closing_runs = {} + next_run_by_length = {} + # Index equal-length runs once instead of repeatedly searching the suffix. + for index in range(len(runs) - 1, -1, -1): + length = runs[index].end() - runs[index].start() + if length in next_run_by_length: + closing_runs[index] = next_run_by_length[length] + next_run_by_length[length] = index + + parts = [] + offset = index = 0 + while index < len(runs): + closing_index = closing_runs.get(index) + if closing_index is None: + index += 1 + continue + start, end = runs[index].start(), runs[closing_index].end() + span = contents[start:end] + if "\n" in span or "\r" in span: + # Multiline spans interact with Markdown block parsing. Preserve + # the whole input's previous behavior rather than re-pairing ticks. + return escape_text(contents) + parts.append(escape_text(contents[offset:start])) + parts.append(span) + offset = end + index = closing_index + 1 + parts.append(escape_text(contents[offset:])) + return "".join(parts) def wrap_signature(signature): diff --git a/test/test_utils.py b/test/test_utils.py index 5d58dc55..77b63e66 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -9,6 +9,7 @@ from typing import Any from unittest import mock +import pytest from docstring_to_markdown import UnknownFormatError from flaky import flaky @@ -234,6 +235,54 @@ def test_clip_column() -> None: assert _utils.clip_column(4, ["123\n", "123"], 1) == 3 +@pytest.mark.parametrize( + ("contents", "expected"), + [ + ( + "Use ``object_hook`` and object_hook.", + r"Use ``object_hook`` and object\_hook.", + ), + ("`*a_b*[c]#d` *a_b*[c]#d", r"`*a_b*[c]#d` \*a\_b\*\[c\]\#d"), + ("``a `b_c` d_e`` f_g", r"``a `b_c` d_e`` f\_g"), + ("`a_b` ``c_d``", "`a_b` ``c_d``"), + ("` `", "` `"), + (r"\`a_b`", r"\\`a_b`"), + (r"\\`a_b`", r"\\\\`a_b`"), + (r"`path\name`", r"`path\name`"), + (r"`a\` tail_word", r"`a\` tail\_word"), + ("`a_b`` c_d", r"`a\_b`` c\_d"), + ("``a_b` c_d`", r"``a\_b` c_d`"), + ("`a_b\nc_d`", "`a\\_b\nc\\_d`"), + ("`a\nb` *outside* `c_d`", "`a\nb` \\*outside\\* `c\\_d`"), + ("`a_b`\n`c_d`", "`a_b`\n`c_d`"), + ("`*a_b*\n# c_d\nend`", "`\\*a\\_b\\*\n\\# c\\_d\nend`"), + ("`a_b\n\nc_d`", "`a\\_b\n\nc\\_d`"), + ("`a_b\n \nc_d`", "`a\\_b\n\u00a0\u00a0\nc\\_d`"), + ("`a_b\r\n\r\nc_d`", "`a\\_b\r\n\r\nc\\_d`"), + ("`a_b\rc_d`", "`a\\_b\rc\\_d`"), + ("`a b\t_c` tail _word", "`a b\t_c` tail\u00a0\u00a0\\_word"), + (r"unmatched `path\name_value", r"unmatched `path\\name\_value"), + ], +) +def test_escape_markdown_code_spans(contents, expected) -> None: + assert _utils.escape_markdown(contents) == expected + + +@pytest.mark.parametrize( + ("markup_kind", "contents", "expected"), + [ + ("markdown", "Use ``object_hook``.", "Use ``object_hook``."), + ("markdown", "Use object_hook.", r"Use object\_hook."), + ("plaintext", "Use ``object_hook``.", "Use ``object_hook``."), + ("markdown", r"Use ``path\name``.", r"Use ``path\name``."), + ], +) +def test_format_docstring_code_spans(markup_kind, contents, expected) -> None: + assert _utils.format_docstring( + contents, markup_kind, signature_config={"formatter": None} + ) == {"kind": markup_kind, "value": expected} + + @mock.patch("docstring_to_markdown.convert") def test_format_docstring_valid_rst_signature(mock_convert) -> None: """Test that a valid RST docstring includes the function signature."""