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