Skip to content

Commit b18e2e7

Browse files
committed
Preserve single-line code spans in fallback docstrings
1 parent a362006 commit b18e2e7

2 files changed

Lines changed: 85 additions & 7 deletions

File tree

‎pylsp/_utils.py‎

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -210,14 +210,43 @@ def escape_plain_text(contents: str) -> str:
210210

211211

212212
def escape_markdown(contents: str) -> str:
213+
"""Escape Markdown syntax outside single-line code spans.
214+
215+
Keep the previous escaping behavior when paired backticks cross a line.
213216
"""
214-
Format plain text to display nicely in Markdown environment.
215-
"""
216-
# escape markdown syntax
217-
contents = re.sub(r"([\\*_#[\]])", r"\\\1", contents)
218-
# preserve white space characters
219-
contents = escape_plain_text(contents)
220-
return contents
217+
218+
def escape_text(text):
219+
return escape_plain_text(re.sub(r"([\\*_#[\]])", r"\\\1", text))
220+
221+
runs = list(re.finditer(r"`+", contents))
222+
closing_runs = {}
223+
next_run_by_length = {}
224+
# Index equal-length runs once instead of repeatedly searching the suffix.
225+
for index in range(len(runs) - 1, -1, -1):
226+
length = runs[index].end() - runs[index].start()
227+
if length in next_run_by_length:
228+
closing_runs[index] = next_run_by_length[length]
229+
next_run_by_length[length] = index
230+
231+
parts = []
232+
offset = index = 0
233+
while index < len(runs):
234+
closing_index = closing_runs.get(index)
235+
if closing_index is None:
236+
index += 1
237+
continue
238+
start, end = runs[index].start(), runs[closing_index].end()
239+
span = contents[start:end]
240+
if "\n" in span or "\r" in span:
241+
# Multiline spans interact with Markdown block parsing. Preserve
242+
# the whole input's previous behavior rather than re-pairing ticks.
243+
return escape_text(contents)
244+
parts.append(escape_text(contents[offset:start]))
245+
parts.append(span)
246+
offset = end
247+
index = closing_index + 1
248+
parts.append(escape_text(contents[offset:]))
249+
return "".join(parts)
221250

222251

223252
def wrap_signature(signature):

‎test/test_utils.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import Any
1010
from unittest import mock
1111

12+
import pytest
1213
from docstring_to_markdown import UnknownFormatError
1314
from flaky import flaky
1415

@@ -234,6 +235,54 @@ def test_clip_column() -> None:
234235
assert _utils.clip_column(4, ["123\n", "123"], 1) == 3
235236

236237

238+
@pytest.mark.parametrize(
239+
("contents", "expected"),
240+
[
241+
(
242+
"Use ``object_hook`` and object_hook.",
243+
r"Use ``object_hook`` and object\_hook.",
244+
),
245+
("`*a_b*[c]#d` *a_b*[c]#d", r"`*a_b*[c]#d` \*a\_b\*\[c\]\#d"),
246+
("``a `b_c` d_e`` f_g", r"``a `b_c` d_e`` f\_g"),
247+
("`a_b` ``c_d``", "`a_b` ``c_d``"),
248+
("` `", "` `"),
249+
(r"\`a_b`", r"\\`a_b`"),
250+
(r"\\`a_b`", r"\\\\`a_b`"),
251+
(r"`path\name`", r"`path\name`"),
252+
(r"`a\` tail_word", r"`a\` tail\_word"),
253+
("`a_b`` c_d", r"`a\_b`` c\_d"),
254+
("``a_b` c_d`", r"``a\_b` c_d`"),
255+
("`a_b\nc_d`", "`a\\_b\nc\\_d`"),
256+
("`a\nb` *outside* `c_d`", "`a\nb` \\*outside\\* `c\\_d`"),
257+
("`a_b`\n`c_d`", "`a_b`\n`c_d`"),
258+
("`*a_b*\n# c_d\nend`", "`\\*a\\_b\\*\n\\# c\\_d\nend`"),
259+
("`a_b\n\nc_d`", "`a\\_b\n\nc\\_d`"),
260+
("`a_b\n \nc_d`", "`a\\_b\n\u00a0\u00a0\nc\\_d`"),
261+
("`a_b\r\n\r\nc_d`", "`a\\_b\r\n\r\nc\\_d`"),
262+
("`a_b\rc_d`", "`a\\_b\rc\\_d`"),
263+
("`a b\t_c` tail _word", "`a b\t_c` tail\u00a0\u00a0\\_word"),
264+
(r"unmatched `path\name_value", r"unmatched `path\\name\_value"),
265+
],
266+
)
267+
def test_escape_markdown_code_spans(contents, expected) -> None:
268+
assert _utils.escape_markdown(contents) == expected
269+
270+
271+
@pytest.mark.parametrize(
272+
("markup_kind", "contents", "expected"),
273+
[
274+
("markdown", "Use ``object_hook``.", "Use ``object_hook``."),
275+
("markdown", "Use object_hook.", r"Use object\_hook."),
276+
("plaintext", "Use ``object_hook``.", "Use ``object_hook``."),
277+
("markdown", r"Use ``path\name``.", r"Use ``path\name``."),
278+
],
279+
)
280+
def test_format_docstring_code_spans(markup_kind, contents, expected) -> None:
281+
assert _utils.format_docstring(
282+
contents, markup_kind, signature_config={"formatter": None}
283+
) == {"kind": markup_kind, "value": expected}
284+
285+
237286
@mock.patch("docstring_to_markdown.convert")
238287
def test_format_docstring_valid_rst_signature(mock_convert) -> None:
239288
"""Test that a valid RST docstring includes the function signature."""

0 commit comments

Comments
 (0)