auto_suggest.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import re
  2. import tokenize
  3. from io import StringIO
  4. from typing import Callable, List, Optional, Union, Generator, Tuple
  5. import warnings
  6. from prompt_toolkit.buffer import Buffer
  7. from prompt_toolkit.key_binding import KeyPressEvent
  8. from prompt_toolkit.key_binding.bindings import named_commands as nc
  9. from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, Suggestion
  10. from prompt_toolkit.document import Document
  11. from prompt_toolkit.history import History
  12. from prompt_toolkit.shortcuts import PromptSession
  13. from prompt_toolkit.layout.processors import (
  14. Processor,
  15. Transformation,
  16. TransformationInput,
  17. )
  18. from IPython.core.getipython import get_ipython
  19. from IPython.utils.tokenutil import generate_tokens
  20. from .filters import pass_through
  21. def _get_query(document: Document):
  22. return document.lines[document.cursor_position_row]
  23. class AppendAutoSuggestionInAnyLine(Processor):
  24. """
  25. Append the auto suggestion to lines other than the last (appending to the
  26. last line is natively supported by the prompt toolkit).
  27. """
  28. def __init__(self, style: str = "class:auto-suggestion") -> None:
  29. self.style = style
  30. def apply_transformation(self, ti: TransformationInput) -> Transformation:
  31. is_last_line = ti.lineno == ti.document.line_count - 1
  32. is_active_line = ti.lineno == ti.document.cursor_position_row
  33. if not is_last_line and is_active_line:
  34. buffer = ti.buffer_control.buffer
  35. if buffer.suggestion and ti.document.is_cursor_at_the_end_of_line:
  36. suggestion = buffer.suggestion.text
  37. else:
  38. suggestion = ""
  39. return Transformation(fragments=ti.fragments + [(self.style, suggestion)])
  40. else:
  41. return Transformation(fragments=ti.fragments)
  42. class NavigableAutoSuggestFromHistory(AutoSuggestFromHistory):
  43. """
  44. A subclass of AutoSuggestFromHistory that allow navigation to next/previous
  45. suggestion from history. To do so it remembers the current position, but it
  46. state need to carefully be cleared on the right events.
  47. """
  48. def __init__(
  49. self,
  50. ):
  51. self.skip_lines = 0
  52. self._connected_apps = []
  53. def reset_history_position(self, _: Buffer):
  54. self.skip_lines = 0
  55. def disconnect(self):
  56. for pt_app in self._connected_apps:
  57. text_insert_event = pt_app.default_buffer.on_text_insert
  58. text_insert_event.remove_handler(self.reset_history_position)
  59. def connect(self, pt_app: PromptSession):
  60. self._connected_apps.append(pt_app)
  61. # note: `on_text_changed` could be used for a bit different behaviour
  62. # on character deletion (i.e. reseting history position on backspace)
  63. pt_app.default_buffer.on_text_insert.add_handler(self.reset_history_position)
  64. pt_app.default_buffer.on_cursor_position_changed.add_handler(self._dismiss)
  65. def get_suggestion(
  66. self, buffer: Buffer, document: Document
  67. ) -> Optional[Suggestion]:
  68. text = _get_query(document)
  69. if text.strip():
  70. for suggestion, _ in self._find_next_match(
  71. text, self.skip_lines, buffer.history
  72. ):
  73. return Suggestion(suggestion)
  74. return None
  75. def _dismiss(self, buffer, *args, **kwargs):
  76. buffer.suggestion = None
  77. def _find_match(
  78. self, text: str, skip_lines: float, history: History, previous: bool
  79. ) -> Generator[Tuple[str, float], None, None]:
  80. """
  81. text : str
  82. Text content to find a match for, the user cursor is most of the
  83. time at the end of this text.
  84. skip_lines : float
  85. number of items to skip in the search, this is used to indicate how
  86. far in the list the user has navigated by pressing up or down.
  87. The float type is used as the base value is +inf
  88. history : History
  89. prompt_toolkit History instance to fetch previous entries from.
  90. previous : bool
  91. Direction of the search, whether we are looking previous match
  92. (True), or next match (False).
  93. Yields
  94. ------
  95. Tuple with:
  96. str:
  97. current suggestion.
  98. float:
  99. will actually yield only ints, which is passed back via skip_lines,
  100. which may be a +inf (float)
  101. """
  102. line_number = -1
  103. for string in reversed(list(history.get_strings())):
  104. for line in reversed(string.splitlines()):
  105. line_number += 1
  106. if not previous and line_number < skip_lines:
  107. continue
  108. # do not return empty suggestions as these
  109. # close the auto-suggestion overlay (and are useless)
  110. if line.startswith(text) and len(line) > len(text):
  111. yield line[len(text) :], line_number
  112. if previous and line_number >= skip_lines:
  113. return
  114. def _find_next_match(
  115. self, text: str, skip_lines: float, history: History
  116. ) -> Generator[Tuple[str, float], None, None]:
  117. return self._find_match(text, skip_lines, history, previous=False)
  118. def _find_previous_match(self, text: str, skip_lines: float, history: History):
  119. return reversed(
  120. list(self._find_match(text, skip_lines, history, previous=True))
  121. )
  122. def up(self, query: str, other_than: str, history: History) -> None:
  123. for suggestion, line_number in self._find_next_match(
  124. query, self.skip_lines, history
  125. ):
  126. # if user has history ['very.a', 'very', 'very.b'] and typed 'very'
  127. # we want to switch from 'very.b' to 'very.a' because a) if the
  128. # suggestion equals current text, prompt-toolkit aborts suggesting
  129. # b) user likely would not be interested in 'very' anyways (they
  130. # already typed it).
  131. if query + suggestion != other_than:
  132. self.skip_lines = line_number
  133. break
  134. else:
  135. # no matches found, cycle back to beginning
  136. self.skip_lines = 0
  137. def down(self, query: str, other_than: str, history: History) -> None:
  138. for suggestion, line_number in self._find_previous_match(
  139. query, self.skip_lines, history
  140. ):
  141. if query + suggestion != other_than:
  142. self.skip_lines = line_number
  143. break
  144. else:
  145. # no matches found, cycle to end
  146. for suggestion, line_number in self._find_previous_match(
  147. query, float("Inf"), history
  148. ):
  149. if query + suggestion != other_than:
  150. self.skip_lines = line_number
  151. break
  152. def accept_or_jump_to_end(event: KeyPressEvent):
  153. """Apply autosuggestion or jump to end of line."""
  154. buffer = event.current_buffer
  155. d = buffer.document
  156. after_cursor = d.text[d.cursor_position :]
  157. lines = after_cursor.split("\n")
  158. end_of_current_line = lines[0].strip()
  159. suggestion = buffer.suggestion
  160. if (suggestion is not None) and (suggestion.text) and (end_of_current_line == ""):
  161. buffer.insert_text(suggestion.text)
  162. else:
  163. nc.end_of_line(event)
  164. def _deprected_accept_in_vi_insert_mode(event: KeyPressEvent):
  165. """Accept autosuggestion or jump to end of line.
  166. .. deprecated:: 8.12
  167. Use `accept_or_jump_to_end` instead.
  168. """
  169. return accept_or_jump_to_end(event)
  170. def accept(event: KeyPressEvent):
  171. """Accept autosuggestion"""
  172. buffer = event.current_buffer
  173. suggestion = buffer.suggestion
  174. if suggestion:
  175. buffer.insert_text(suggestion.text)
  176. else:
  177. nc.forward_char(event)
  178. def discard(event: KeyPressEvent):
  179. """Discard autosuggestion"""
  180. buffer = event.current_buffer
  181. buffer.suggestion = None
  182. def accept_word(event: KeyPressEvent):
  183. """Fill partial autosuggestion by word"""
  184. buffer = event.current_buffer
  185. suggestion = buffer.suggestion
  186. if suggestion:
  187. t = re.split(r"(\S+\s+)", suggestion.text)
  188. buffer.insert_text(next((x for x in t if x), ""))
  189. else:
  190. nc.forward_word(event)
  191. def accept_character(event: KeyPressEvent):
  192. """Fill partial autosuggestion by character"""
  193. b = event.current_buffer
  194. suggestion = b.suggestion
  195. if suggestion and suggestion.text:
  196. b.insert_text(suggestion.text[0])
  197. def accept_and_keep_cursor(event: KeyPressEvent):
  198. """Accept autosuggestion and keep cursor in place"""
  199. buffer = event.current_buffer
  200. old_position = buffer.cursor_position
  201. suggestion = buffer.suggestion
  202. if suggestion:
  203. buffer.insert_text(suggestion.text)
  204. buffer.cursor_position = old_position
  205. def accept_and_move_cursor_left(event: KeyPressEvent):
  206. """Accept autosuggestion and move cursor left in place"""
  207. accept_and_keep_cursor(event)
  208. nc.backward_char(event)
  209. def _update_hint(buffer: Buffer):
  210. if buffer.auto_suggest:
  211. suggestion = buffer.auto_suggest.get_suggestion(buffer, buffer.document)
  212. buffer.suggestion = suggestion
  213. def backspace_and_resume_hint(event: KeyPressEvent):
  214. """Resume autosuggestions after deleting last character"""
  215. nc.backward_delete_char(event)
  216. _update_hint(event.current_buffer)
  217. def resume_hinting(event: KeyPressEvent):
  218. """Resume autosuggestions"""
  219. pass_through.reply(event)
  220. # Order matters: if update happened first and event reply second, the
  221. # suggestion would be auto-accepted if both actions are bound to same key.
  222. _update_hint(event.current_buffer)
  223. def up_and_update_hint(event: KeyPressEvent):
  224. """Go up and update hint"""
  225. current_buffer = event.current_buffer
  226. current_buffer.auto_up(count=event.arg)
  227. _update_hint(current_buffer)
  228. def down_and_update_hint(event: KeyPressEvent):
  229. """Go down and update hint"""
  230. current_buffer = event.current_buffer
  231. current_buffer.auto_down(count=event.arg)
  232. _update_hint(current_buffer)
  233. def accept_token(event: KeyPressEvent):
  234. """Fill partial autosuggestion by token"""
  235. b = event.current_buffer
  236. suggestion = b.suggestion
  237. if suggestion:
  238. prefix = _get_query(b.document)
  239. text = prefix + suggestion.text
  240. tokens: List[Optional[str]] = [None, None, None]
  241. substrings = [""]
  242. i = 0
  243. for token in generate_tokens(StringIO(text).readline):
  244. if token.type == tokenize.NEWLINE:
  245. index = len(text)
  246. else:
  247. index = text.index(token[1], len(substrings[-1]))
  248. substrings.append(text[:index])
  249. tokenized_so_far = substrings[-1]
  250. if tokenized_so_far.startswith(prefix):
  251. if i == 0 and len(tokenized_so_far) > len(prefix):
  252. tokens[0] = tokenized_so_far[len(prefix) :]
  253. substrings.append(tokenized_so_far)
  254. i += 1
  255. tokens[i] = token[1]
  256. if i == 2:
  257. break
  258. i += 1
  259. if tokens[0]:
  260. to_insert: str
  261. insert_text = substrings[-2]
  262. if tokens[1] and len(tokens[1]) == 1:
  263. insert_text = substrings[-1]
  264. to_insert = insert_text[len(prefix) :]
  265. b.insert_text(to_insert)
  266. return
  267. nc.forward_word(event)
  268. Provider = Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
  269. def _swap_autosuggestion(
  270. buffer: Buffer,
  271. provider: NavigableAutoSuggestFromHistory,
  272. direction_method: Callable,
  273. ):
  274. """
  275. We skip most recent history entry (in either direction) if it equals the
  276. current autosuggestion because if user cycles when auto-suggestion is shown
  277. they most likely want something else than what was suggested (otherwise
  278. they would have accepted the suggestion).
  279. """
  280. suggestion = buffer.suggestion
  281. if not suggestion:
  282. return
  283. query = _get_query(buffer.document)
  284. current = query + suggestion.text
  285. direction_method(query=query, other_than=current, history=buffer.history)
  286. new_suggestion = provider.get_suggestion(buffer, buffer.document)
  287. buffer.suggestion = new_suggestion
  288. def swap_autosuggestion_up(event: KeyPressEvent):
  289. """Get next autosuggestion from history."""
  290. shell = get_ipython()
  291. provider = shell.auto_suggest
  292. if not isinstance(provider, NavigableAutoSuggestFromHistory):
  293. return
  294. return _swap_autosuggestion(
  295. buffer=event.current_buffer, provider=provider, direction_method=provider.up
  296. )
  297. def swap_autosuggestion_down(event: KeyPressEvent):
  298. """Get previous autosuggestion from history."""
  299. shell = get_ipython()
  300. provider = shell.auto_suggest
  301. if not isinstance(provider, NavigableAutoSuggestFromHistory):
  302. return
  303. return _swap_autosuggestion(
  304. buffer=event.current_buffer,
  305. provider=provider,
  306. direction_method=provider.down,
  307. )
  308. def __getattr__(key):
  309. if key == "accept_in_vi_insert_mode":
  310. warnings.warn(
  311. "`accept_in_vi_insert_mode` is deprecated since IPython 8.12 and "
  312. "renamed to `accept_or_jump_to_end`. Please update your configuration "
  313. "accordingly",
  314. DeprecationWarning,
  315. stacklevel=2,
  316. )
  317. return _deprected_accept_in_vi_insert_mode
  318. raise AttributeError