Repository: Textualize/rich (python)

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
```

Pull request description:
[bugfix] solve crash when using `inspect()` on the "pyparsing" package
## Type of changes

- [x] Bug fix - fixes #2284 
- [ ] New feature
- [ ] Documentation / docstrings
- [ ] Tests
- [ ] Other

:warning: This PR introduces a (minor) breaking change, in order to fix a (minor) issued described with the screenshots below. :point_down: 
But we can of course choose to keep the existing behaviour as-is instead :slightly_smiling_face: 

## Checklist

- [x] I've run the latest [black](https://github.com/psf/black) with default args on new code.
- [ ] I've updated CHANGELOG.md and CONTRIBUTORS.md where appropriate. `(N/A)`
- [x] I've added tests for new code.
- [x] I accept that @willmcgugan may be pedantic in the code review.

## Description

As reported [there](https://github.com/Textualize/rich/issues/2284), running this command was making Rich crash:
```py
python3 -c "import pyparsing; from rich import inspect; inspect(pyparsing, methods=True)"
```

It took me a while to understand what was going wrong, as the code from which the exception pops is in `Text.divide(offsets)` which is quite a complex part :exploding_head: 
But in the end I identified the culprits :ninja: : special ASCII characters! (in docstrings)

We were removing them from the content of the textual content of the `Text` class, but when caching the length of this content we were using the length of the non-sanitised text - which causes issues in some cases, as the text we're splitting into several lines doesn't actually have the length we think it has.

### Should we make control characters contained in docstrings visible in the inspection?

I first solved the bug by just making sure the cached length of the text is the one of the sanitised version of it, but then I noticed that the result was looking a bit odd with docstrings that contains such special characters, such as the `WordStart` class of this pyparsing package.

 - When dumped with `inspect` we were ending up with _"To emulate the ```` behavior of regular expressions"_, with 4 backticks in a row without content:
![Screenshot from 2022-05-25 10-56-56](https://user-images.githubusercontent.com/722388/170235974-023b89dc-bcd3-489a-aed7-077da98f36d9.png)

 - That's why I opted for another strategy: replacing these control codes with their "readable" equivalents, so `\b` is displayed as "\b":
![Screenshot from 2022-05-25 10-58-53](https://user-images.githubusercontent.com/722388/170236339-3ddc4c9d-dfb6-47b7-8f19-f5b86dd54882.png)

However, although it's rather unlikely I appreciate that doing such a change in Rich's behaviour could break people's code if they were using some parsing over the result of `rich.inspect` for example.
It depends I guess on whether or not we consider the stripping of control codes in docstrings, when inspecting code, as a feature of a bug? :thinking: 
If it's feature we may rather choose to keep the existing behaviour for the moment, and potentially change it later on with a major version bump, or an opt-in flag somewhere in Rich? :slightly_smiling_face: 



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc33feab..f8083da2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Fixed
 
 - Allow exceptions that are raised while a Live is rendered to be displayed and/or processed https://github.com/Textualize/rich/pull/2305
+- Fix crashes that can happen with `inspect` when docstrings contain some special control codes https://github.com/Textualize/rich/pull/2294
 
 ## [12.4.4] - 2022-05-24
 
diff --git a/pyproject.toml b/pyproject.toml
index 1b16acf9..f2edc90a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -61,3 +61,6 @@ enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
 [[tool.mypy.overrides]]
 module = ["pygments.*", "IPython.*", "commonmark.*", "ipywidgets.*"]
 ignore_missing_imports = true
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
diff --git a/rich/_inspect.py b/rich/_inspect.py
index 01713e57..af440340 100644
--- a/rich/_inspect.py
+++ b/rich/_inspect.py
@@ -5,6 +5,7 @@ from inspect import cleandoc, getdoc, getfile, isclass, ismodule, signature
 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 @@ class Inspect(JupyterMixin):
                 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 @@ class Inspect(JupyterMixin):
                     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 @@ class Inspect(JupyterMixin):
                 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
index 747311fc..a8a91255 100644
--- 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 @@ class Control:
 
 
 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
index f6b349d6..1471fac2 100644
--- a/rich/text.py
+++ b/rich/text.py
@@ -2,7 +2,6 @@ import re
 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 @@ class Text(JupyterMixin):
         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 @@ class Text(JupyterMixin):
         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 @@ class Text(JupyterMixin):
     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 @@ class Text(JupyterMixin):
 
         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
index 987206d8..d2704e98 100644
--- 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
index b4c1d2a7..d2e2d86c 100644
--- a/tests/test_inspect.py
+++ b/tests/test_inspect.py
@@ -299,3 +299,41 @@ def test_inspect_module_with_class():
         "╰──────────────────────────────────────────╯\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