Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/prompt_toolkit/document.py: 23%
528 statements
« prev ^ index » next coverage.py v7.2.2, created at 2023-03-26 06:07 +0000
« prev ^ index » next coverage.py v7.2.2, created at 2023-03-26 06:07 +0000
1"""
2The `Document` that implements all the text operations/querying.
3"""
4from __future__ import annotations
6import bisect
7import re
8import string
9import weakref
10from typing import (
11 Callable,
12 Dict,
13 Iterable,
14 List,
15 NoReturn,
16 Optional,
17 Pattern,
18 Tuple,
19 cast,
20)
22from .clipboard import ClipboardData
23from .filters import vi_mode
24from .selection import PasteMode, SelectionState, SelectionType
26__all__ = [
27 "Document",
28]
31# Regex for finding "words" in documents. (We consider a group of alnum
32# characters a word, but also a group of special characters a word, as long as
33# it doesn't contain a space.)
34# (This is a 'word' in Vi.)
35_FIND_WORD_RE = re.compile(r"([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)")
36_FIND_CURRENT_WORD_RE = re.compile(r"^([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)")
37_FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile(
38 r"^(([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)\s*)"
39)
41# Regex for finding "WORDS" in documents.
42# (This is a 'WORD in Vi.)
43_FIND_BIG_WORD_RE = re.compile(r"([^\s]+)")
44_FIND_CURRENT_BIG_WORD_RE = re.compile(r"^([^\s]+)")
45_FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile(r"^([^\s]+\s*)")
47# Share the Document._cache between all Document instances.
48# (Document instances are considered immutable. That means that if another
49# `Document` is constructed with the same text, it should have the same
50# `_DocumentCache`.)
51_text_to_document_cache: dict[str, _DocumentCache] = cast(
52 Dict[str, "_DocumentCache"],
53 weakref.WeakValueDictionary(), # Maps document.text to DocumentCache instance.
54)
57class _ImmutableLineList(List[str]):
58 """
59 Some protection for our 'lines' list, which is assumed to be immutable in the cache.
60 (Useful for detecting obvious bugs.)
61 """
63 def _error(self, *a: object, **kw: object) -> NoReturn:
64 raise NotImplementedError("Attempt to modify an immutable list.")
66 __setitem__ = _error # type: ignore
67 append = _error
68 clear = _error
69 extend = _error
70 insert = _error
71 pop = _error
72 remove = _error
73 reverse = _error
74 sort = _error # type: ignore
77class _DocumentCache:
78 def __init__(self) -> None:
79 #: List of lines for the Document text.
80 self.lines: _ImmutableLineList | None = None
82 #: List of index positions, pointing to the start of all the lines.
83 self.line_indexes: list[int] | None = None
86class Document:
87 """
88 This is a immutable class around the text and cursor position, and contains
89 methods for querying this data, e.g. to give the text before the cursor.
91 This class is usually instantiated by a :class:`~prompt_toolkit.buffer.Buffer`
92 object, and accessed as the `document` property of that class.
94 :param text: string
95 :param cursor_position: int
96 :param selection: :class:`.SelectionState`
97 """
99 __slots__ = ("_text", "_cursor_position", "_selection", "_cache")
101 def __init__(
102 self,
103 text: str = "",
104 cursor_position: int | None = None,
105 selection: SelectionState | None = None,
106 ) -> None:
107 # Check cursor position. It can also be right after the end. (Where we
108 # insert text.)
109 assert cursor_position is None or cursor_position <= len(text), AssertionError(
110 f"cursor_position={cursor_position!r}, len_text={len(text)!r}"
111 )
113 # By default, if no cursor position was given, make sure to put the
114 # cursor position is at the end of the document. This is what makes
115 # sense in most places.
116 if cursor_position is None:
117 cursor_position = len(text)
119 # Keep these attributes private. A `Document` really has to be
120 # considered to be immutable, because otherwise the caching will break
121 # things. Because of that, we wrap these into read-only properties.
122 self._text = text
123 self._cursor_position = cursor_position
124 self._selection = selection
126 # Cache for lines/indexes. (Shared with other Document instances that
127 # contain the same text.
128 try:
129 self._cache = _text_to_document_cache[self.text]
130 except KeyError:
131 self._cache = _DocumentCache()
132 _text_to_document_cache[self.text] = self._cache
134 # XX: For some reason, above, we can't use 'WeakValueDictionary.setdefault'.
135 # This fails in Pypy3. `self._cache` becomes None, because that's what
136 # 'setdefault' returns.
137 # self._cache = _text_to_document_cache.setdefault(self.text, _DocumentCache())
138 # assert self._cache
140 def __repr__(self) -> str:
141 return f"{self.__class__.__name__}({self.text!r}, {self.cursor_position!r})"
143 def __eq__(self, other: object) -> bool:
144 if not isinstance(other, Document):
145 return False
147 return (
148 self.text == other.text
149 and self.cursor_position == other.cursor_position
150 and self.selection == other.selection
151 )
153 @property
154 def text(self) -> str:
155 "The document text."
156 return self._text
158 @property
159 def cursor_position(self) -> int:
160 "The document cursor position."
161 return self._cursor_position
163 @property
164 def selection(self) -> SelectionState | None:
165 ":class:`.SelectionState` object."
166 return self._selection
168 @property
169 def current_char(self) -> str:
170 """Return character under cursor or an empty string."""
171 return self._get_char_relative_to_cursor(0) or ""
173 @property
174 def char_before_cursor(self) -> str:
175 """Return character before the cursor or an empty string."""
176 return self._get_char_relative_to_cursor(-1) or ""
178 @property
179 def text_before_cursor(self) -> str:
180 return self.text[: self.cursor_position :]
182 @property
183 def text_after_cursor(self) -> str:
184 return self.text[self.cursor_position :]
186 @property
187 def current_line_before_cursor(self) -> str:
188 """Text from the start of the line until the cursor."""
189 _, _, text = self.text_before_cursor.rpartition("\n")
190 return text
192 @property
193 def current_line_after_cursor(self) -> str:
194 """Text from the cursor until the end of the line."""
195 text, _, _ = self.text_after_cursor.partition("\n")
196 return text
198 @property
199 def lines(self) -> list[str]:
200 """
201 Array of all the lines.
202 """
203 # Cache, because this one is reused very often.
204 if self._cache.lines is None:
205 self._cache.lines = _ImmutableLineList(self.text.split("\n"))
207 return self._cache.lines
209 @property
210 def _line_start_indexes(self) -> list[int]:
211 """
212 Array pointing to the start indexes of all the lines.
213 """
214 # Cache, because this is often reused. (If it is used, it's often used
215 # many times. And this has to be fast for editing big documents!)
216 if self._cache.line_indexes is None:
217 # Create list of line lengths.
218 line_lengths = map(len, self.lines)
220 # Calculate cumulative sums.
221 indexes = [0]
222 append = indexes.append
223 pos = 0
225 for line_length in line_lengths:
226 pos += line_length + 1
227 append(pos)
229 # Remove the last item. (This is not a new line.)
230 if len(indexes) > 1:
231 indexes.pop()
233 self._cache.line_indexes = indexes
235 return self._cache.line_indexes
237 @property
238 def lines_from_current(self) -> list[str]:
239 """
240 Array of the lines starting from the current line, until the last line.
241 """
242 return self.lines[self.cursor_position_row :]
244 @property
245 def line_count(self) -> int:
246 r"""Return the number of lines in this document. If the document ends
247 with a trailing \n, that counts as the beginning of a new line."""
248 return len(self.lines)
250 @property
251 def current_line(self) -> str:
252 """Return the text on the line where the cursor is. (when the input
253 consists of just one line, it equals `text`."""
254 return self.current_line_before_cursor + self.current_line_after_cursor
256 @property
257 def leading_whitespace_in_current_line(self) -> str:
258 """The leading whitespace in the left margin of the current line."""
259 current_line = self.current_line
260 length = len(current_line) - len(current_line.lstrip())
261 return current_line[:length]
263 def _get_char_relative_to_cursor(self, offset: int = 0) -> str:
264 """
265 Return character relative to cursor position, or empty string
266 """
267 try:
268 return self.text[self.cursor_position + offset]
269 except IndexError:
270 return ""
272 @property
273 def on_first_line(self) -> bool:
274 """
275 True when we are at the first line.
276 """
277 return self.cursor_position_row == 0
279 @property
280 def on_last_line(self) -> bool:
281 """
282 True when we are at the last line.
283 """
284 return self.cursor_position_row == self.line_count - 1
286 @property
287 def cursor_position_row(self) -> int:
288 """
289 Current row. (0-based.)
290 """
291 row, _ = self._find_line_start_index(self.cursor_position)
292 return row
294 @property
295 def cursor_position_col(self) -> int:
296 """
297 Current column. (0-based.)
298 """
299 # (Don't use self.text_before_cursor to calculate this. Creating
300 # substrings and doing rsplit is too expensive for getting the cursor
301 # position.)
302 _, line_start_index = self._find_line_start_index(self.cursor_position)
303 return self.cursor_position - line_start_index
305 def _find_line_start_index(self, index: int) -> tuple[int, int]:
306 """
307 For the index of a character at a certain line, calculate the index of
308 the first character on that line.
310 Return (row, index) tuple.
311 """
312 indexes = self._line_start_indexes
314 pos = bisect.bisect_right(indexes, index) - 1
315 return pos, indexes[pos]
317 def translate_index_to_position(self, index: int) -> tuple[int, int]:
318 """
319 Given an index for the text, return the corresponding (row, col) tuple.
320 (0-based. Returns (0, 0) for index=0.)
321 """
322 # Find start of this line.
323 row, row_index = self._find_line_start_index(index)
324 col = index - row_index
326 return row, col
328 def translate_row_col_to_index(self, row: int, col: int) -> int:
329 """
330 Given a (row, col) tuple, return the corresponding index.
331 (Row and col params are 0-based.)
333 Negative row/col values are turned into zero.
334 """
335 try:
336 result = self._line_start_indexes[row]
337 line = self.lines[row]
338 except IndexError:
339 if row < 0:
340 result = self._line_start_indexes[0]
341 line = self.lines[0]
342 else:
343 result = self._line_start_indexes[-1]
344 line = self.lines[-1]
346 result += max(0, min(col, len(line)))
348 # Keep in range. (len(self.text) is included, because the cursor can be
349 # right after the end of the text as well.)
350 result = max(0, min(result, len(self.text)))
351 return result
353 @property
354 def is_cursor_at_the_end(self) -> bool:
355 """True when the cursor is at the end of the text."""
356 return self.cursor_position == len(self.text)
358 @property
359 def is_cursor_at_the_end_of_line(self) -> bool:
360 """True when the cursor is at the end of this line."""
361 return self.current_char in ("\n", "")
363 def has_match_at_current_position(self, sub: str) -> bool:
364 """
365 `True` when this substring is found at the cursor position.
366 """
367 return self.text.find(sub, self.cursor_position) == self.cursor_position
369 def find(
370 self,
371 sub: str,
372 in_current_line: bool = False,
373 include_current_position: bool = False,
374 ignore_case: bool = False,
375 count: int = 1,
376 ) -> int | None:
377 """
378 Find `text` after the cursor, return position relative to the cursor
379 position. Return `None` if nothing was found.
381 :param count: Find the n-th occurrence.
382 """
383 assert isinstance(ignore_case, bool)
385 if in_current_line:
386 text = self.current_line_after_cursor
387 else:
388 text = self.text_after_cursor
390 if not include_current_position:
391 if len(text) == 0:
392 return None # (Otherwise, we always get a match for the empty string.)
393 else:
394 text = text[1:]
396 flags = re.IGNORECASE if ignore_case else 0
397 iterator = re.finditer(re.escape(sub), text, flags)
399 try:
400 for i, match in enumerate(iterator):
401 if i + 1 == count:
402 if include_current_position:
403 return match.start(0)
404 else:
405 return match.start(0) + 1
406 except StopIteration:
407 pass
408 return None
410 def find_all(self, sub: str, ignore_case: bool = False) -> list[int]:
411 """
412 Find all occurrences of the substring. Return a list of absolute
413 positions in the document.
414 """
415 flags = re.IGNORECASE if ignore_case else 0
416 return [a.start() for a in re.finditer(re.escape(sub), self.text, flags)]
418 def find_backwards(
419 self,
420 sub: str,
421 in_current_line: bool = False,
422 ignore_case: bool = False,
423 count: int = 1,
424 ) -> int | None:
425 """
426 Find `text` before the cursor, return position relative to the cursor
427 position. Return `None` if nothing was found.
429 :param count: Find the n-th occurrence.
430 """
431 if in_current_line:
432 before_cursor = self.current_line_before_cursor[::-1]
433 else:
434 before_cursor = self.text_before_cursor[::-1]
436 flags = re.IGNORECASE if ignore_case else 0
437 iterator = re.finditer(re.escape(sub[::-1]), before_cursor, flags)
439 try:
440 for i, match in enumerate(iterator):
441 if i + 1 == count:
442 return -match.start(0) - len(sub)
443 except StopIteration:
444 pass
445 return None
447 def get_word_before_cursor(
448 self, WORD: bool = False, pattern: Pattern[str] | None = None
449 ) -> str:
450 """
451 Give the word before the cursor.
452 If we have whitespace before the cursor this returns an empty string.
454 :param pattern: (None or compiled regex). When given, use this regex
455 pattern.
456 """
457 if self._is_word_before_cursor_complete(WORD=WORD, pattern=pattern):
458 # Space before the cursor or no text before cursor.
459 return ""
461 text_before_cursor = self.text_before_cursor
462 start = self.find_start_of_previous_word(WORD=WORD, pattern=pattern) or 0
464 return text_before_cursor[len(text_before_cursor) + start :]
466 def _is_word_before_cursor_complete(
467 self, WORD: bool = False, pattern: Pattern[str] | None = None
468 ) -> bool:
469 if pattern:
470 return self.find_start_of_previous_word(WORD=WORD, pattern=pattern) is None
471 else:
472 return (
473 self.text_before_cursor == "" or self.text_before_cursor[-1:].isspace()
474 )
476 def find_start_of_previous_word(
477 self, count: int = 1, WORD: bool = False, pattern: Pattern[str] | None = None
478 ) -> int | None:
479 """
480 Return an index relative to the cursor position pointing to the start
481 of the previous word. Return `None` if nothing was found.
483 :param pattern: (None or compiled regex). When given, use this regex
484 pattern.
485 """
486 assert not (WORD and pattern)
488 # Reverse the text before the cursor, in order to do an efficient
489 # backwards search.
490 text_before_cursor = self.text_before_cursor[::-1]
492 if pattern:
493 regex = pattern
494 elif WORD:
495 regex = _FIND_BIG_WORD_RE
496 else:
497 regex = _FIND_WORD_RE
499 iterator = regex.finditer(text_before_cursor)
501 try:
502 for i, match in enumerate(iterator):
503 if i + 1 == count:
504 return -match.end(0)
505 except StopIteration:
506 pass
507 return None
509 def find_boundaries_of_current_word(
510 self,
511 WORD: bool = False,
512 include_leading_whitespace: bool = False,
513 include_trailing_whitespace: bool = False,
514 ) -> tuple[int, int]:
515 """
516 Return the relative boundaries (startpos, endpos) of the current word under the
517 cursor. (This is at the current line, because line boundaries obviously
518 don't belong to any word.)
519 If not on a word, this returns (0,0)
520 """
521 text_before_cursor = self.current_line_before_cursor[::-1]
522 text_after_cursor = self.current_line_after_cursor
524 def get_regex(include_whitespace: bool) -> Pattern[str]:
525 return {
526 (False, False): _FIND_CURRENT_WORD_RE,
527 (False, True): _FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE,
528 (True, False): _FIND_CURRENT_BIG_WORD_RE,
529 (True, True): _FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE,
530 }[(WORD, include_whitespace)]
532 match_before = get_regex(include_leading_whitespace).search(text_before_cursor)
533 match_after = get_regex(include_trailing_whitespace).search(text_after_cursor)
535 # When there is a match before and after, and we're not looking for
536 # WORDs, make sure that both the part before and after the cursor are
537 # either in the [a-zA-Z_] alphabet or not. Otherwise, drop the part
538 # before the cursor.
539 if not WORD and match_before and match_after:
540 c1 = self.text[self.cursor_position - 1]
541 c2 = self.text[self.cursor_position]
542 alphabet = string.ascii_letters + "0123456789_"
544 if (c1 in alphabet) != (c2 in alphabet):
545 match_before = None
547 return (
548 -match_before.end(1) if match_before else 0,
549 match_after.end(1) if match_after else 0,
550 )
552 def get_word_under_cursor(self, WORD: bool = False) -> str:
553 """
554 Return the word, currently below the cursor.
555 This returns an empty string when the cursor is on a whitespace region.
556 """
557 start, end = self.find_boundaries_of_current_word(WORD=WORD)
558 return self.text[self.cursor_position + start : self.cursor_position + end]
560 def find_next_word_beginning(
561 self, count: int = 1, WORD: bool = False
562 ) -> int | None:
563 """
564 Return an index relative to the cursor position pointing to the start
565 of the next word. Return `None` if nothing was found.
566 """
567 if count < 0:
568 return self.find_previous_word_beginning(count=-count, WORD=WORD)
570 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
571 iterator = regex.finditer(self.text_after_cursor)
573 try:
574 for i, match in enumerate(iterator):
575 # Take first match, unless it's the word on which we're right now.
576 if i == 0 and match.start(1) == 0:
577 count += 1
579 if i + 1 == count:
580 return match.start(1)
581 except StopIteration:
582 pass
583 return None
585 def find_next_word_ending(
586 self, include_current_position: bool = False, count: int = 1, WORD: bool = False
587 ) -> int | None:
588 """
589 Return an index relative to the cursor position pointing to the end
590 of the next word. Return `None` if nothing was found.
591 """
592 if count < 0:
593 return self.find_previous_word_ending(count=-count, WORD=WORD)
595 if include_current_position:
596 text = self.text_after_cursor
597 else:
598 text = self.text_after_cursor[1:]
600 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
601 iterable = regex.finditer(text)
603 try:
604 for i, match in enumerate(iterable):
605 if i + 1 == count:
606 value = match.end(1)
608 if include_current_position:
609 return value
610 else:
611 return value + 1
613 except StopIteration:
614 pass
615 return None
617 def find_previous_word_beginning(
618 self, count: int = 1, WORD: bool = False
619 ) -> int | None:
620 """
621 Return an index relative to the cursor position pointing to the start
622 of the previous word. Return `None` if nothing was found.
623 """
624 if count < 0:
625 return self.find_next_word_beginning(count=-count, WORD=WORD)
627 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
628 iterator = regex.finditer(self.text_before_cursor[::-1])
630 try:
631 for i, match in enumerate(iterator):
632 if i + 1 == count:
633 return -match.end(1)
634 except StopIteration:
635 pass
636 return None
638 def find_previous_word_ending(
639 self, count: int = 1, WORD: bool = False
640 ) -> int | None:
641 """
642 Return an index relative to the cursor position pointing to the end
643 of the previous word. Return `None` if nothing was found.
644 """
645 if count < 0:
646 return self.find_next_word_ending(count=-count, WORD=WORD)
648 text_before_cursor = self.text_after_cursor[:1] + self.text_before_cursor[::-1]
650 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
651 iterator = regex.finditer(text_before_cursor)
653 try:
654 for i, match in enumerate(iterator):
655 # Take first match, unless it's the word on which we're right now.
656 if i == 0 and match.start(1) == 0:
657 count += 1
659 if i + 1 == count:
660 return -match.start(1) + 1
661 except StopIteration:
662 pass
663 return None
665 def find_next_matching_line(
666 self, match_func: Callable[[str], bool], count: int = 1
667 ) -> int | None:
668 """
669 Look downwards for empty lines.
670 Return the line index, relative to the current line.
671 """
672 result = None
674 for index, line in enumerate(self.lines[self.cursor_position_row + 1 :]):
675 if match_func(line):
676 result = 1 + index
677 count -= 1
679 if count == 0:
680 break
682 return result
684 def find_previous_matching_line(
685 self, match_func: Callable[[str], bool], count: int = 1
686 ) -> int | None:
687 """
688 Look upwards for empty lines.
689 Return the line index, relative to the current line.
690 """
691 result = None
693 for index, line in enumerate(self.lines[: self.cursor_position_row][::-1]):
694 if match_func(line):
695 result = -1 - index
696 count -= 1
698 if count == 0:
699 break
701 return result
703 def get_cursor_left_position(self, count: int = 1) -> int:
704 """
705 Relative position for cursor left.
706 """
707 if count < 0:
708 return self.get_cursor_right_position(-count)
710 return -min(self.cursor_position_col, count)
712 def get_cursor_right_position(self, count: int = 1) -> int:
713 """
714 Relative position for cursor_right.
715 """
716 if count < 0:
717 return self.get_cursor_left_position(-count)
719 return min(count, len(self.current_line_after_cursor))
721 def get_cursor_up_position(
722 self, count: int = 1, preferred_column: int | None = None
723 ) -> int:
724 """
725 Return the relative cursor position (character index) where we would be if the
726 user pressed the arrow-up button.
728 :param preferred_column: When given, go to this column instead of
729 staying at the current column.
730 """
731 assert count >= 1
732 column = (
733 self.cursor_position_col if preferred_column is None else preferred_column
734 )
736 return (
737 self.translate_row_col_to_index(
738 max(0, self.cursor_position_row - count), column
739 )
740 - self.cursor_position
741 )
743 def get_cursor_down_position(
744 self, count: int = 1, preferred_column: int | None = None
745 ) -> int:
746 """
747 Return the relative cursor position (character index) where we would be if the
748 user pressed the arrow-down button.
750 :param preferred_column: When given, go to this column instead of
751 staying at the current column.
752 """
753 assert count >= 1
754 column = (
755 self.cursor_position_col if preferred_column is None else preferred_column
756 )
758 return (
759 self.translate_row_col_to_index(self.cursor_position_row + count, column)
760 - self.cursor_position
761 )
763 def find_enclosing_bracket_right(
764 self, left_ch: str, right_ch: str, end_pos: int | None = None
765 ) -> int | None:
766 """
767 Find the right bracket enclosing current position. Return the relative
768 position to the cursor position.
770 When `end_pos` is given, don't look past the position.
771 """
772 if self.current_char == right_ch:
773 return 0
775 if end_pos is None:
776 end_pos = len(self.text)
777 else:
778 end_pos = min(len(self.text), end_pos)
780 stack = 1
782 # Look forward.
783 for i in range(self.cursor_position + 1, end_pos):
784 c = self.text[i]
786 if c == left_ch:
787 stack += 1
788 elif c == right_ch:
789 stack -= 1
791 if stack == 0:
792 return i - self.cursor_position
794 return None
796 def find_enclosing_bracket_left(
797 self, left_ch: str, right_ch: str, start_pos: int | None = None
798 ) -> int | None:
799 """
800 Find the left bracket enclosing current position. Return the relative
801 position to the cursor position.
803 When `start_pos` is given, don't look past the position.
804 """
805 if self.current_char == left_ch:
806 return 0
808 if start_pos is None:
809 start_pos = 0
810 else:
811 start_pos = max(0, start_pos)
813 stack = 1
815 # Look backward.
816 for i in range(self.cursor_position - 1, start_pos - 1, -1):
817 c = self.text[i]
819 if c == right_ch:
820 stack += 1
821 elif c == left_ch:
822 stack -= 1
824 if stack == 0:
825 return i - self.cursor_position
827 return None
829 def find_matching_bracket_position(
830 self, start_pos: int | None = None, end_pos: int | None = None
831 ) -> int:
832 """
833 Return relative cursor position of matching [, (, { or < bracket.
835 When `start_pos` or `end_pos` are given. Don't look past the positions.
836 """
838 # Look for a match.
839 for pair in "()", "[]", "{}", "<>":
840 A = pair[0]
841 B = pair[1]
842 if self.current_char == A:
843 return self.find_enclosing_bracket_right(A, B, end_pos=end_pos) or 0
844 elif self.current_char == B:
845 return self.find_enclosing_bracket_left(A, B, start_pos=start_pos) or 0
847 return 0
849 def get_start_of_document_position(self) -> int:
850 """Relative position for the start of the document."""
851 return -self.cursor_position
853 def get_end_of_document_position(self) -> int:
854 """Relative position for the end of the document."""
855 return len(self.text) - self.cursor_position
857 def get_start_of_line_position(self, after_whitespace: bool = False) -> int:
858 """Relative position for the start of this line."""
859 if after_whitespace:
860 current_line = self.current_line
861 return (
862 len(current_line)
863 - len(current_line.lstrip())
864 - self.cursor_position_col
865 )
866 else:
867 return -len(self.current_line_before_cursor)
869 def get_end_of_line_position(self) -> int:
870 """Relative position for the end of this line."""
871 return len(self.current_line_after_cursor)
873 def last_non_blank_of_current_line_position(self) -> int:
874 """
875 Relative position for the last non blank character of this line.
876 """
877 return len(self.current_line.rstrip()) - self.cursor_position_col - 1
879 def get_column_cursor_position(self, column: int) -> int:
880 """
881 Return the relative cursor position for this column at the current
882 line. (It will stay between the boundaries of the line in case of a
883 larger number.)
884 """
885 line_length = len(self.current_line)
886 current_column = self.cursor_position_col
887 column = max(0, min(line_length, column))
889 return column - current_column
891 def selection_range(
892 self,
893 ) -> tuple[
894 int, int
895 ]: # XXX: shouldn't this return `None` if there is no selection???
896 """
897 Return (from, to) tuple of the selection.
898 start and end position are included.
900 This doesn't take the selection type into account. Use
901 `selection_ranges` instead.
902 """
903 if self.selection:
904 from_, to = sorted(
905 [self.cursor_position, self.selection.original_cursor_position]
906 )
907 else:
908 from_, to = self.cursor_position, self.cursor_position
910 return from_, to
912 def selection_ranges(self) -> Iterable[tuple[int, int]]:
913 """
914 Return a list of `(from, to)` tuples for the selection or none if
915 nothing was selected. The upper boundary is not included.
917 This will yield several (from, to) tuples in case of a BLOCK selection.
918 This will return zero ranges, like (8,8) for empty lines in a block
919 selection.
920 """
921 if self.selection:
922 from_, to = sorted(
923 [self.cursor_position, self.selection.original_cursor_position]
924 )
926 if self.selection.type == SelectionType.BLOCK:
927 from_line, from_column = self.translate_index_to_position(from_)
928 to_line, to_column = self.translate_index_to_position(to)
929 from_column, to_column = sorted([from_column, to_column])
930 lines = self.lines
932 if vi_mode():
933 to_column += 1
935 for l in range(from_line, to_line + 1):
936 line_length = len(lines[l])
938 if from_column <= line_length:
939 yield (
940 self.translate_row_col_to_index(l, from_column),
941 self.translate_row_col_to_index(
942 l, min(line_length, to_column)
943 ),
944 )
945 else:
946 # In case of a LINES selection, go to the start/end of the lines.
947 if self.selection.type == SelectionType.LINES:
948 from_ = max(0, self.text.rfind("\n", 0, from_) + 1)
950 if self.text.find("\n", to) >= 0:
951 to = self.text.find("\n", to)
952 else:
953 to = len(self.text) - 1
955 # In Vi mode, the upper boundary is always included. For Emacs,
956 # that's not the case.
957 if vi_mode():
958 to += 1
960 yield from_, to
962 def selection_range_at_line(self, row: int) -> tuple[int, int] | None:
963 """
964 If the selection spans a portion of the given line, return a (from, to) tuple.
966 The returned upper boundary is not included in the selection, so
967 `(0, 0)` is an empty selection. `(0, 1)`, is a one character selection.
969 Returns None if the selection doesn't cover this line at all.
970 """
971 if self.selection:
972 line = self.lines[row]
974 row_start = self.translate_row_col_to_index(row, 0)
975 row_end = self.translate_row_col_to_index(row, len(line))
977 from_, to = sorted(
978 [self.cursor_position, self.selection.original_cursor_position]
979 )
981 # Take the intersection of the current line and the selection.
982 intersection_start = max(row_start, from_)
983 intersection_end = min(row_end, to)
985 if intersection_start <= intersection_end:
986 if self.selection.type == SelectionType.LINES:
987 intersection_start = row_start
988 intersection_end = row_end
990 elif self.selection.type == SelectionType.BLOCK:
991 _, col1 = self.translate_index_to_position(from_)
992 _, col2 = self.translate_index_to_position(to)
993 col1, col2 = sorted([col1, col2])
995 if col1 > len(line):
996 return None # Block selection doesn't cross this line.
998 intersection_start = self.translate_row_col_to_index(row, col1)
999 intersection_end = self.translate_row_col_to_index(row, col2)
1001 _, from_column = self.translate_index_to_position(intersection_start)
1002 _, to_column = self.translate_index_to_position(intersection_end)
1004 # In Vi mode, the upper boundary is always included. For Emacs
1005 # mode, that's not the case.
1006 if vi_mode():
1007 to_column += 1
1009 return from_column, to_column
1010 return None
1012 def cut_selection(self) -> tuple[Document, ClipboardData]:
1013 """
1014 Return a (:class:`.Document`, :class:`.ClipboardData`) tuple, where the
1015 document represents the new document when the selection is cut, and the
1016 clipboard data, represents whatever has to be put on the clipboard.
1017 """
1018 if self.selection:
1019 cut_parts = []
1020 remaining_parts = []
1021 new_cursor_position = self.cursor_position
1023 last_to = 0
1024 for from_, to in self.selection_ranges():
1025 if last_to == 0:
1026 new_cursor_position = from_
1028 remaining_parts.append(self.text[last_to:from_])
1029 cut_parts.append(self.text[from_:to])
1030 last_to = to
1032 remaining_parts.append(self.text[last_to:])
1034 cut_text = "\n".join(cut_parts)
1035 remaining_text = "".join(remaining_parts)
1037 # In case of a LINES selection, don't include the trailing newline.
1038 if self.selection.type == SelectionType.LINES and cut_text.endswith("\n"):
1039 cut_text = cut_text[:-1]
1041 return (
1042 Document(text=remaining_text, cursor_position=new_cursor_position),
1043 ClipboardData(cut_text, self.selection.type),
1044 )
1045 else:
1046 return self, ClipboardData("")
1048 def paste_clipboard_data(
1049 self,
1050 data: ClipboardData,
1051 paste_mode: PasteMode = PasteMode.EMACS,
1052 count: int = 1,
1053 ) -> Document:
1054 """
1055 Return a new :class:`.Document` instance which contains the result if
1056 we would paste this data at the current cursor position.
1058 :param paste_mode: Where to paste. (Before/after/emacs.)
1059 :param count: When >1, Paste multiple times.
1060 """
1061 before = paste_mode == PasteMode.VI_BEFORE
1062 after = paste_mode == PasteMode.VI_AFTER
1064 if data.type == SelectionType.CHARACTERS:
1065 if after:
1066 new_text = (
1067 self.text[: self.cursor_position + 1]
1068 + data.text * count
1069 + self.text[self.cursor_position + 1 :]
1070 )
1071 else:
1072 new_text = (
1073 self.text_before_cursor + data.text * count + self.text_after_cursor
1074 )
1076 new_cursor_position = self.cursor_position + len(data.text) * count
1077 if before:
1078 new_cursor_position -= 1
1080 elif data.type == SelectionType.LINES:
1081 l = self.cursor_position_row
1082 if before:
1083 lines = self.lines[:l] + [data.text] * count + self.lines[l:]
1084 new_text = "\n".join(lines)
1085 new_cursor_position = len("".join(self.lines[:l])) + l
1086 else:
1087 lines = self.lines[: l + 1] + [data.text] * count + self.lines[l + 1 :]
1088 new_cursor_position = len("".join(self.lines[: l + 1])) + l + 1
1089 new_text = "\n".join(lines)
1091 elif data.type == SelectionType.BLOCK:
1092 lines = self.lines[:]
1093 start_line = self.cursor_position_row
1094 start_column = self.cursor_position_col + (0 if before else 1)
1096 for i, line in enumerate(data.text.split("\n")):
1097 index = i + start_line
1098 if index >= len(lines):
1099 lines.append("")
1101 lines[index] = lines[index].ljust(start_column)
1102 lines[index] = (
1103 lines[index][:start_column]
1104 + line * count
1105 + lines[index][start_column:]
1106 )
1108 new_text = "\n".join(lines)
1109 new_cursor_position = self.cursor_position + (0 if before else 1)
1111 return Document(text=new_text, cursor_position=new_cursor_position)
1113 def empty_line_count_at_the_end(self) -> int:
1114 """
1115 Return number of empty lines at the end of the document.
1116 """
1117 count = 0
1118 for line in self.lines[::-1]:
1119 if not line or line.isspace():
1120 count += 1
1121 else:
1122 break
1124 return count
1126 def start_of_paragraph(self, count: int = 1, before: bool = False) -> int:
1127 """
1128 Return the start of the current paragraph. (Relative cursor position.)
1129 """
1131 def match_func(text: str) -> bool:
1132 return not text or text.isspace()
1134 line_index = self.find_previous_matching_line(
1135 match_func=match_func, count=count
1136 )
1138 if line_index:
1139 add = 0 if before else 1
1140 return min(0, self.get_cursor_up_position(count=-line_index) + add)
1141 else:
1142 return -self.cursor_position
1144 def end_of_paragraph(self, count: int = 1, after: bool = False) -> int:
1145 """
1146 Return the end of the current paragraph. (Relative cursor position.)
1147 """
1149 def match_func(text: str) -> bool:
1150 return not text or text.isspace()
1152 line_index = self.find_next_matching_line(match_func=match_func, count=count)
1154 if line_index:
1155 add = 0 if after else 1
1156 return max(0, self.get_cursor_down_position(count=line_index) - add)
1157 else:
1158 return len(self.text_after_cursor)
1160 # Modifiers.
1162 def insert_after(self, text: str) -> Document:
1163 """
1164 Create a new document, with this text inserted after the buffer.
1165 It keeps selection ranges and cursor position in sync.
1166 """
1167 return Document(
1168 text=self.text + text,
1169 cursor_position=self.cursor_position,
1170 selection=self.selection,
1171 )
1173 def insert_before(self, text: str) -> Document:
1174 """
1175 Create a new document, with this text inserted before the buffer.
1176 It keeps selection ranges and cursor position in sync.
1177 """
1178 selection_state = self.selection
1180 if selection_state:
1181 selection_state = SelectionState(
1182 original_cursor_position=selection_state.original_cursor_position
1183 + len(text),
1184 type=selection_state.type,
1185 )
1187 return Document(
1188 text=text + self.text,
1189 cursor_position=self.cursor_position + len(text),
1190 selection=selection_state,
1191 )