1"""prompt-toolkit utilities
2
3Everything in this module is a private API,
4not to be used outside IPython.
5"""
6from __future__ import annotations
7
8# Copyright (c) IPython Development Team.
9# Distributed under the terms of the Modified BSD License.
10
11import unicodedata
12from wcwidth import wcwidth
13
14from IPython.core.completer import (
15 provisionalcompleter, cursor_to_position,
16 _deduplicate_completions)
17from prompt_toolkit.completion import Completer, Completion
18from prompt_toolkit.lexers import Lexer
19from prompt_toolkit.lexers import PygmentsLexer
20from prompt_toolkit.patch_stdout import patch_stdout
21
22
23import pygments.lexers as pygments_lexers
24import os
25import sys
26import traceback
27
28_completion_sentinel = object()
29
30
31def _elide_point(string: str, *, min_elide) -> str:
32 """
33 If a string is long enough, and has at least 3 dots,
34 replace the middle part with ellipses.
35
36 If a string naming a file is long enough, and has at least 3 slashes,
37 replace the middle part with ellipses.
38
39 If three consecutive dots, or two consecutive dots are encountered these are
40 replaced by the equivalents HORIZONTAL ELLIPSIS or TWO DOT LEADER unicode
41 equivalents
42 """
43 if min_elide <= 0:
44 return string
45 string = string.replace('...','\N{HORIZONTAL ELLIPSIS}')
46 string = string.replace('..','\N{TWO DOT LEADER}')
47 if len(string) < min_elide:
48 return string
49
50 object_parts = string.split('.')
51 file_parts = string.split(os.sep)
52 if file_parts[-1] == '':
53 file_parts.pop()
54
55 if len(object_parts) > 3:
56 return "{}.{}\N{HORIZONTAL ELLIPSIS}{}.{}".format(
57 object_parts[0],
58 object_parts[1][:1],
59 object_parts[-2][-1:],
60 object_parts[-1],
61 )
62
63 elif len(file_parts) > 3:
64 return ("{}" + os.sep + "{}\N{HORIZONTAL ELLIPSIS}{}" + os.sep + "{}").format(
65 file_parts[0], file_parts[1][:1], file_parts[-2][-1:], file_parts[-1]
66 )
67
68 return string
69
70
71def _elide_typed(string: str, typed: str, *, min_elide: int) -> str:
72 """
73 Elide the middle of a long string if the beginning has already been typed.
74 """
75
76 if min_elide <= 0:
77 return string
78 if len(string) < min_elide:
79 return string
80 cut_how_much = len(typed)-3
81 if cut_how_much < 7:
82 return string
83 if string.startswith(typed) and len(string)> len(typed):
84 return f"{string[:3]}\N{HORIZONTAL ELLIPSIS}{string[cut_how_much:]}"
85 return string
86
87
88def _elide(string: str, typed: str, min_elide) -> str:
89 return _elide_typed(
90 _elide_point(string, min_elide=min_elide),
91 typed, min_elide=min_elide)
92
93
94
95def _adjust_completion_text_based_on_context(text, body, offset):
96 if text.endswith('=') and len(body) > offset and body[offset] == '=':
97 return text[:-1]
98 else:
99 return text
100
101
102class IPythonPTCompleter(Completer):
103 """Adaptor to provide IPython completions to prompt_toolkit"""
104 def __init__(self, ipy_completer=None, shell=None):
105 if shell is None and ipy_completer is None:
106 raise TypeError("Please pass shell=an InteractiveShell instance.")
107 self._ipy_completer = ipy_completer
108 self.shell = shell
109
110 @property
111 def ipy_completer(self):
112 if self._ipy_completer:
113 return self._ipy_completer
114 else:
115 return self.shell.Completer
116
117 def get_completions(self, document, complete_event):
118 if not document.current_line.strip():
119 return
120 # Some bits of our completion system may print stuff (e.g. if a module
121 # is imported). This context manager ensures that doesn't interfere with
122 # the prompt.
123
124 with patch_stdout(), provisionalcompleter():
125 body = document.text
126 cursor_row = document.cursor_position_row
127 cursor_col = document.cursor_position_col
128 cursor_position = document.cursor_position
129 offset = cursor_to_position(body, cursor_row, cursor_col)
130 try:
131 yield from self._get_completions(body, offset, cursor_position, self.ipy_completer)
132 except Exception as e:
133 try:
134 exc_type, exc_value, exc_tb = sys.exc_info()
135 traceback.print_exception(exc_type, exc_value, exc_tb)
136 except AttributeError:
137 print('Unrecoverable Error in completions')
138
139 def _get_completions(self, body, offset, cursor_position, ipyc):
140 """
141 Private equivalent of get_completions() use only for unit_testing.
142 """
143 debug = getattr(ipyc, 'debug', False)
144 completions = _deduplicate_completions(
145 body, ipyc.completions(body, offset))
146 for c in completions:
147 if not c.text:
148 # Guard against completion machinery giving us an empty string.
149 continue
150 text = unicodedata.normalize('NFC', c.text)
151 # When the first character of the completion has a zero length,
152 # then it's probably a decomposed unicode character. E.g. caused by
153 # the "\dot" completion. Try to compose again with the previous
154 # character.
155 if wcwidth(text[0]) == 0:
156 if cursor_position + c.start > 0:
157 char_before = body[c.start - 1]
158 fixed_text = unicodedata.normalize(
159 'NFC', char_before + text)
160
161 # Yield the modified completion instead, if this worked.
162 if wcwidth(text[0:1]) == 1:
163 yield Completion(fixed_text, start_position=c.start - offset - 1)
164 continue
165
166 # TODO: Use Jedi to determine meta_text
167 # (Jedi currently has a bug that results in incorrect information.)
168 # meta_text = ''
169 # yield Completion(m, start_position=start_pos,
170 # display_meta=meta_text)
171 display_text = c.text
172
173 adjusted_text = _adjust_completion_text_based_on_context(
174 c.text, body, offset
175 )
176 min_elide = 30 if self.shell is None else self.shell.min_elide
177 if c.type == "function":
178 yield Completion(
179 adjusted_text,
180 start_position=c.start - offset,
181 display=_elide(
182 display_text + "()",
183 body[c.start : c.end],
184 min_elide=min_elide,
185 ),
186 display_meta=c.type + c.signature,
187 )
188 else:
189 yield Completion(
190 adjusted_text,
191 start_position=c.start - offset,
192 display=_elide(
193 display_text,
194 body[c.start : c.end],
195 min_elide=min_elide,
196 ),
197 display_meta=c.type,
198 )
199
200
201class IPythonPTLexer(Lexer):
202 """
203 Wrapper around PythonLexer and BashLexer.
204 """
205 def __init__(self):
206 l = pygments_lexers
207 self.python_lexer = PygmentsLexer(l.Python3Lexer)
208 self.shell_lexer = PygmentsLexer(l.BashLexer)
209
210 self.magic_lexers = {
211 'HTML': PygmentsLexer(l.HtmlLexer),
212 'html': PygmentsLexer(l.HtmlLexer),
213 'javascript': PygmentsLexer(l.JavascriptLexer),
214 'js': PygmentsLexer(l.JavascriptLexer),
215 'perl': PygmentsLexer(l.PerlLexer),
216 'ruby': PygmentsLexer(l.RubyLexer),
217 'latex': PygmentsLexer(l.TexLexer),
218 }
219
220 def lex_document(self, document):
221 text = document.text.lstrip()
222
223 lexer = self.python_lexer
224
225 if text.startswith('!') or text.startswith('%%bash'):
226 lexer = self.shell_lexer
227
228 elif text.startswith('%%'):
229 for magic, l in self.magic_lexers.items():
230 if text.startswith('%%' + magic):
231 lexer = l
232 break
233
234 return lexer.lex_document(document)