Repository: Textualize/rich

Issue:
IndexError: list index out of range when using inspect(pyparsing, all=True)
OS: macOS Monterey 12.4
CPU: Intel
Python version:

`Python 3.10.4 | packaged by conda-forge | (main, Mar 24 2022, 17:43:32) [Clang 12.0.1 ]`

`rich` and `pyparsing` version: 12.2.0 and 3.0.8

```
❯ conda list rich
# packages in environment at /usr/local/Caskroom/miniconda/base:
#
# Name                    Version                   Build  Channel
rich                      12.2.0             pyhd8ed1ab_0    conda-forge
❯ conda list pyparsing
# packages in environment at /usr/local/Caskroom/miniconda/base:
#
# Name                    Version                   Build  Channel
pyparsing                 3.0.8              pyhd8ed1ab_0    conda-forge
```

Traceback:

```
$ python3 -c "import pyparsing; from rich import inspect; inspect(pyparsing, all=True)" # similar error when using `methods=True`

Traceback (most recent call last):
  File "<string>", line 1, in <module>
    import pyparsing; from rich import inspect; inspect(pyparsing, all=True)
                                                │       └ <module 'pyparsing' from '/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/pyparsing/__init__.py'>
                                                └ <function inspect at 0x10849e5f0>
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/__init__.py", line 168, in inspect
    _console.print(_inspect)
    │              └ <rich._inspect.Inspect object at 0x1084b5b70>
    └ <console width=160 None>
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/console.py", line 1752, in print
    extend(render(renderable, render_options))
    │      │      │           └ ConsoleOptions(size=ConsoleDimensions(width=160, height=48), legacy_windows=False, min_width=1, max_width=160, is_terminal=False...
    │      │      └ <rich.panel.Panel object at 0x1087b9ed0>
    │      └ <bound method Console.render of <console width=160 None>>
    └ <built-in method extend of list object at 0x1087d9340>
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/console.py", line 1390, in render
    for render_output in iter_render:
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/panel.py", line 175, in __rich_console__
    lines = console.render_lines(renderable, child_options, style=style)
            │                    │           │                    └ Style()
            │                    │           └ ConsoleOptions(size=ConsoleDimensions(width=160, height=48), legacy_windows=False, min_width=158, max_width=158, is_terminal=Fal...
            │                    └ Padding(<rich.console.Group object at 0x1087ba320>, (0,1,0,1))
            └ <console width=160 None>
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/console.py", line 1425, in render_lines
    lines = list(
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/segment.py", line 288, in split_and_crop_lines
    for segment in segments:
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/console.py", line 1390, in render
    for render_output in iter_render:
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/padding.py", line 97, in __rich_console__
    lines = console.render_lines(
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/console.py", line 1425, in render_lines
    lines = list(
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/segment.py", line 288, in split_and_crop_lines
    for segment in segments:
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/console.py", line 1394, in render
    yield from self.render(render_output, _options)
               │           │              └ ConsoleOptions(size=ConsoleDimensions(width=160, height=48), legacy_windows=False, min_width=156, max_width=156, is_terminal=Fal...
               │           └ <rich.table.Table object at 0x1087ba2f0>
               └ <console width=160 None>
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/console.py", line 1390, in render
    for render_output in iter_render:
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/table.py", line 505, in __rich_console__
    yield from self._render(console, render_options, widths)
               │            │        │               └ [31, 125]
               │            │        └ ConsoleOptions(size=ConsoleDimensions(width=160, height=48), legacy_windows=False, min_width=156, max_width=156, is_terminal=Fal...
               │            └ <console width=160 None>
               └ <rich.table.Table object at 0x1087ba2f0>
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/table.py", line 818, in _render
    lines = console.render_lines(
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/console.py", line 1425, in render_lines
    lines = list(
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/segment.py", line 288, in split_and_crop_lines
    for segment in segments:
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/console.py", line 1390, in render
    for render_output in iter_render:
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/padding.py", line 97, in __rich_console__
    lines = console.render_lines(
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/console.py", line 1425, in render_lines
    lines = list(
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/segment.py", line 288, in split_and_crop_lines
    for segment in segments:
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/console.py", line 1390, in render
    for render_output in iter_render:
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/text.py", line 632, in __rich_console__
    lines = self.wrap(
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/text.py", line 1155, in wrap
    for line in self.split(allow_blank=True):
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/text.py", line 1021, in split
    line for line in self.divide(flatten_spans()) if line.plain != separator
  File "/usr/local/Caskroom/miniconda/base/lib/python3.10/site-packages/rich/text.py", line 1091, in divide
    line_start, line_end = line_ranges[end_line_no]
    │           │          │           └ 13
    │           │          └ [(0, 252), (252, 253), (253, 318), (318, 319), (319, 388), (388, 389), (389, 447), (447, 448), (448, 508), (508, 509), (509, 577...
    │           └ 588
    └ 578
IndexError: list index out of range
```


Discussion:

Resolving patch:
diff --git a/rich/_inspect.py b/rich/_inspect.py
--- a/rich/_inspect.py
+++ b/rich/_inspect.py
@@ -5,6 +5,7 @@
 from typing import Any, Iterable, Optional, Tuple
 
 from .console import Group, RenderableType
+from .control import escape_control_codes
 from .highlighter import ReprHighlighter
 from .jupyter import JupyterMixin
 from .panel import Panel
@@ -19,12 +20,6 @@ def _first_paragraph(doc: str) -> str:
     return paragraph
 
 
-def _reformat_doc(doc: str) -> str:
-    """Reformat docstring."""
-    doc = cleandoc(doc).strip()
-    return doc
-
-
 class Inspect(JupyterMixin):
     """A renderable to inspect any Python Object.
 
@@ -161,11 +156,9 @@ def safe_getattr(attr_name: str) -> Tuple[Any, Any]:
                 yield ""
 
         if self.docs:
-            _doc = getdoc(obj)
+            _doc = self._get_formatted_doc(obj)
             if _doc is not None:
-                if not self.help:
-                    _doc = _first_paragraph(_doc)
-                doc_text = Text(_reformat_doc(_doc), style="inspect.help")
+                doc_text = Text(_doc, style="inspect.help")
                 doc_text = highlighter(doc_text)
                 yield doc_text
                 yield ""
@@ -200,13 +193,10 @@ def safe_getattr(attr_name: str) -> Tuple[Any, Any]:
                     add_row(key_text, Pretty(value, highlighter=highlighter))
                 else:
                     if self.docs:
-                        docs = getdoc(value)
+                        docs = self._get_formatted_doc(value)
                         if docs is not None:
-                            _doc = _reformat_doc(str(docs))
-                            if not self.help:
-                                _doc = _first_paragraph(_doc)
-                            _signature_text.append("\n" if "\n" in _doc else " ")
-                            doc = highlighter(_doc)
+                            _signature_text.append("\n" if "\n" in docs else " ")
+                            doc = highlighter(docs)
                             doc.stylize("inspect.doc")
                             _signature_text.append(doc)
 
@@ -220,3 +210,24 @@ def safe_getattr(attr_name: str) -> Tuple[Any, Any]:
                 f"[b cyan]{not_shown_count}[/][i] attribute(s) not shown.[/i] "
                 f"Run [b][magenta]inspect[/]([not b]inspect[/])[/b] for options."
             )
+
+    def _get_formatted_doc(self, object_: Any) -> Optional[str]:
+        """
+        Extract the docstring of an object, process it and returns it.
+        The processing consists in cleaning up the doctring's indentation,
+        taking only its 1st paragraph if `self.help` is not True,
+        and escape its control codes.
+
+        Args:
+            object_ (Any): the object to get the docstring from.
+
+        Returns:
+            Optional[str]: the processed docstring, or None if no docstring was found.
+        """
+        docs = getdoc(object_)
+        if docs is None:
+            return None
+        docs = cleandoc(docs).strip()
+        if not self.help:
+            docs = _first_paragraph(docs)
+        return escape_control_codes(docs)
diff --git a/rich/control.py b/rich/control.py
--- a/rich/control.py
+++ b/rich/control.py
@@ -1,19 +1,35 @@
+import sys
 import time
 from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Union
 
+if sys.version_info >= (3, 8):
+    from typing import Final
+else:
+    from typing_extensions import Final  # pragma: no cover
+
 from .segment import ControlCode, ControlType, Segment
 
 if TYPE_CHECKING:
     from .console import Console, ConsoleOptions, RenderResult
 
-STRIP_CONTROL_CODES = [
+STRIP_CONTROL_CODES: Final = [
+    7,  # Bell
     8,  # Backspace
     11,  # Vertical tab
     12,  # Form feed
     13,  # Carriage return
 ]
-_CONTROL_TRANSLATE = {_codepoint: None for _codepoint in STRIP_CONTROL_CODES}
+_CONTROL_STRIP_TRANSLATE: Final = {
+    _codepoint: None for _codepoint in STRIP_CONTROL_CODES
+}
 
+CONTROL_ESCAPE: Final = {
+    7: "\\a",
+    8: "\\b",
+    11: "\\v",
+    12: "\\f",
+    13: "\\r",
+}
 
 CONTROL_CODES_FORMAT: Dict[int, Callable[..., str]] = {
     ControlType.BELL: lambda: "\x07",
@@ -169,7 +185,7 @@ def __rich_console__(
 
 
 def strip_control_codes(
-    text: str, _translate_table: Dict[int, None] = _CONTROL_TRANSLATE
+    text: str, _translate_table: Dict[int, None] = _CONTROL_STRIP_TRANSLATE
 ) -> str:
     """Remove control codes from text.
 
@@ -182,6 +198,22 @@ def strip_control_codes(
     return text.translate(_translate_table)
 
 
+def escape_control_codes(
+    text: str,
+    _translate_table: Dict[int, str] = CONTROL_ESCAPE,
+) -> str:
+    """Replace control codes with their "escaped" equivalent in the given text.
+    (e.g. "\b" becomes "\\b")
+
+    Args:
+        text (str): A string possibly containing control codes.
+
+    Returns:
+        str: String with control codes replaced with their escaped version.
+    """
+    return text.translate(_translate_table)
+
+
 if __name__ == "__main__":  # pragma: no cover
     from rich.console import Console
 
diff --git a/rich/text.py b/rich/text.py
--- a/rich/text.py
+++ b/rich/text.py
@@ -2,7 +2,6 @@
 from functools import partial, reduce
 from math import gcd
 from operator import itemgetter
-from rich.emoji import EmojiVariant
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -141,7 +140,8 @@ def __init__(
         tab_size: Optional[int] = 8,
         spans: Optional[List[Span]] = None,
     ) -> None:
-        self._text = [strip_control_codes(text)]
+        sanitized_text = strip_control_codes(text)
+        self._text = [sanitized_text]
         self.style = style
         self.justify: Optional["JustifyMethod"] = justify
         self.overflow: Optional["OverflowMethod"] = overflow
@@ -149,7 +149,7 @@ def __init__(
         self.end = end
         self.tab_size = tab_size
         self._spans: List[Span] = spans or []
-        self._length: int = len(text)
+        self._length: int = len(sanitized_text)
 
     def __len__(self) -> int:
         return self._length
@@ -394,9 +394,10 @@ def plain(self) -> str:
     def plain(self, new_text: str) -> None:
         """Set the text to a new value."""
         if new_text != self.plain:
-            self._text[:] = [new_text]
+            sanitized_text = strip_control_codes(new_text)
+            self._text[:] = [sanitized_text]
             old_length = self._length
-            self._length = len(new_text)
+            self._length = len(sanitized_text)
             if old_length > self._length:
                 self._trim_spans()
 
@@ -906,10 +907,10 @@ def append(
 
         if len(text):
             if isinstance(text, str):
-                text = strip_control_codes(text)
-                self._text.append(text)
+                sanitized_text = strip_control_codes(text)
+                self._text.append(sanitized_text)
                 offset = len(self)
-                text_length = len(text)
+                text_length = len(sanitized_text)
                 if style is not None:
                     self._spans.append(Span(offset, offset + text_length, style))
                 self._length += text_length


Test patch:
diff --git a/tests/test_control.py b/tests/test_control.py
--- a/tests/test_control.py
+++ b/tests/test_control.py
@@ -1,4 +1,4 @@
-from rich.control import Control, strip_control_codes
+from rich.control import Control, escape_control_codes, strip_control_codes
 from rich.segment import ControlType, Segment
 
 
@@ -13,6 +13,12 @@ def test_strip_control_codes():
     assert strip_control_codes("Fear is the mind killer") == "Fear is the mind killer"
 
 
+def test_escape_control_codes():
+    assert escape_control_codes("") == ""
+    assert escape_control_codes("foo\rbar") == "foo\\rbar"
+    assert escape_control_codes("Fear is the mind killer") == "Fear is the mind killer"
+
+
 def test_control_move_to():
     control = Control.move_to(5, 10)
     print(control.segment)
diff --git a/tests/test_inspect.py b/tests/test_inspect.py
--- a/tests/test_inspect.py
+++ b/tests/test_inspect.py
@@ -299,3 +299,41 @@ class Thing:
         "╰──────────────────────────────────────────╯\n"
     )
     assert render(module, methods=True) == expected
+
+
+@pytest.mark.parametrize(
+    "special_character,expected_replacement",
+    (
+        ("\a", "\\a"),
+        ("\b", "\\b"),
+        ("\f", "\\f"),
+        ("\r", "\\r"),
+        ("\v", "\\v"),
+    ),
+)
+def test_can_handle_special_characters_in_docstrings(
+    special_character: str, expected_replacement: str
+):
+    class Something:
+        class Thing:
+            pass
+
+    Something.Thing.__doc__ = f"""
+    Multiline docstring
+    with {special_character} should be handled
+    """
+
+    expected = """\
+╭─ <class 'tests.test_inspect.test_can_handle_sp─╮
+│ class test_can_handle_special_characters_in_do │
+│ cstrings.<locals>.Something():                 │
+│                                                │
+│ Thing = class Thing():                         │
+│         Multiline docstring                    │
+│         with %s should be handled              │
+╰────────────────────────────────────────────────╯
+""" % (
+        expected_replacement
+    )
+
+    assert render(Something, methods=True) == expected