summaryrefslogtreecommitdiffstats
path: root/ptpython/utils.py
blob: ef96ca4bedafc4cf9c8daf793946085ec5a914cb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
"""
For internal use only.
"""
import re
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Dict,
    Iterable,
    Optional,
    Type,
    TypeVar,
    cast,
)

from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import to_formatted_text
from prompt_toolkit.formatted_text.utils import fragment_list_to_text
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType

if TYPE_CHECKING:
    from jedi import Interpreter

    # See: prompt_toolkit/key_binding/key_bindings.py
    # Annotating these return types as `object` is what works best, because
    # `NotImplemented` is typed `Any`.
    NotImplementedOrNone = object

__all__ = [
    "has_unclosed_brackets",
    "get_jedi_script_from_document",
    "document_is_multiline_python",
    "unindent_code",
]


def has_unclosed_brackets(text: str) -> bool:
    """
    Starting at the end of the string. If we find an opening bracket
    for which we didn't had a closing one yet, return True.
    """
    stack = []

    # Ignore braces inside strings
    text = re.sub(r"""('[^']*'|"[^"]*")""", "", text)  # XXX: handle escaped quotes.!

    for c in reversed(text):
        if c in "])}":
            stack.append(c)

        elif c in "[({":
            if stack:
                if (
                    (c == "[" and stack[-1] == "]")
                    or (c == "{" and stack[-1] == "}")
                    or (c == "(" and stack[-1] == ")")
                ):
                    stack.pop()
            else:
                # Opening bracket for which we didn't had a closing one.
                return True

    return False


def get_jedi_script_from_document(
    document: Document, locals: Dict[str, Any], globals: Dict[str, Any]
) -> "Interpreter":
    import jedi  # We keep this import in-line, to improve start-up time.

    # Importing Jedi is 'slow'.

    try:
        return jedi.Interpreter(
            document.text,
            path="input-text",
            namespaces=[locals, globals],
        )
    except ValueError:
        # Invalid cursor position.
        # ValueError('`column` parameter is not in a valid range.')
        return None
    except AttributeError:
        # Workaround for #65: https://github.com/jonathanslenders/python-prompt-toolkit/issues/65
        # See also: https://github.com/davidhalter/jedi/issues/508
        return None
    except IndexError:
        # Workaround Jedi issue #514: for https://github.com/davidhalter/jedi/issues/514
        return None
    except KeyError:
        # Workaroud for a crash when the input is "u'", the start of a unicode string.
        return None
    except Exception:
        # Workaround for: https://github.com/jonathanslenders/ptpython/issues/91
        return None


_multiline_string_delims = re.compile("""[']{3}|["]{3}""")


def document_is_multiline_python(document: Document) -> bool:
    """
    Determine whether this is a multiline Python document.
    """

    def ends_in_multiline_string() -> bool:
        """
        ``True`` if we're inside a multiline string at the end of the text.
        """
        delims = _multiline_string_delims.findall(document.text)
        opening = None
        for delim in delims:
            if opening is None:
                opening = delim
            elif delim == opening:
                opening = None
        return bool(opening)

    if "\n" in document.text or ends_in_multiline_string():
        return True

    def line_ends_with_colon() -> bool:
        return document.current_line.rstrip()[-1:] == ":"

    # If we just typed a colon, or still have open brackets, always insert a real newline.
    if (
        line_ends_with_colon()
        or (
            document.is_cursor_at_the_end
            and has_unclosed_brackets(document.text_before_cursor)
        )
        or document.text.startswith("@")
    ):
        return True

    # If the character before the cursor is a backslash (line continuation
    # char), insert a new line.
    elif document.text_before_cursor[-1:] == "\\":
        return True

    return False


_T = TypeVar("_T", bound=Callable[[MouseEvent], None])


def if_mousedown(handler: _T) -> _T:
    """
    Decorator for mouse handlers.
    Only handle event when the user pressed mouse down.

    (When applied to a token list. Scroll events will bubble up and are handled
    by the Window.)
    """

    def handle_if_mouse_down(mouse_event: MouseEvent) -> "NotImplementedOrNone":
        if mouse_event.event_type == MouseEventType.MOUSE_DOWN:
            return handler(mouse_event)
        else:
            return NotImplemented

    return cast(_T, handle_if_mouse_down)


_T_type = TypeVar("_T_type", bound=type)


def ptrepr_to_repr(cls: _T_type) -> _T_type:
    """
    Generate a normal `__repr__` method for classes that have a `__pt_repr__`.
    """
    if not hasattr(cls, "__pt_repr__"):
        raise TypeError(
            "@ptrepr_to_repr can only be applied to classes that have a `__pt_repr__` method."
        )

    def __repr__(self: object) -> str:
        assert hasattr(cls, "__pt_repr__")
        return fragment_list_to_text(to_formatted_text(cls.__pt_repr__(self)))

    cls.__repr__ = __repr__  # type:ignore
    return cls


def unindent_code(text: str) -> str:
    """
    Remove common leading whitespace when all lines are indented.
    """
    lines = text.splitlines(keepends=True)

    # Look for common prefix.
    common_prefix = _common_whitespace_prefix(lines)

    # Remove indentation.
    lines = [line[len(common_prefix) :] for line in lines]

    return "".join(lines)


def _common_whitespace_prefix(strings: Iterable[str]) -> str:
    """
    Return common prefix for a list of lines.
    This will ignore lines that contain whitespace only.
    """
    # Ignore empty lines and lines that have whitespace only.
    strings = [s for s in strings if not s.isspace() and not len(s) == 0]

    if not strings:
        return ""

    else:
        s1 = min(strings)
        s2 = max(strings)

        for i, c in enumerate(s1):
            if c != s2[i] or c not in " \t":
                return s1[:i]

        return s1