ptutils.py 7.3 KB

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