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.4.4, created at 2024-04-20 06:09 +0000
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-20 06:09 +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 Callable, Dict, Iterable, List, NoReturn, Pattern, cast
12from .clipboard import ClipboardData
13from .filters import vi_mode
14from .selection import PasteMode, SelectionState, SelectionType
16__all__ = [
17 "Document",
18]
21# Regex for finding "words" in documents. (We consider a group of alnum
22# characters a word, but also a group of special characters a word, as long as
23# it doesn't contain a space.)
24# (This is a 'word' in Vi.)
25_FIND_WORD_RE = re.compile(r"([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)")
26_FIND_CURRENT_WORD_RE = re.compile(r"^([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)")
27_FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile(
28 r"^(([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)\s*)"
29)
31# Regex for finding "WORDS" in documents.
32# (This is a 'WORD in Vi.)
33_FIND_BIG_WORD_RE = re.compile(r"([^\s]+)")
34_FIND_CURRENT_BIG_WORD_RE = re.compile(r"^([^\s]+)")
35_FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile(r"^([^\s]+\s*)")
37# Share the Document._cache between all Document instances.
38# (Document instances are considered immutable. That means that if another
39# `Document` is constructed with the same text, it should have the same
40# `_DocumentCache`.)
41_text_to_document_cache: dict[str, _DocumentCache] = cast(
42 Dict[str, "_DocumentCache"],
43 weakref.WeakValueDictionary(), # Maps document.text to DocumentCache instance.
44)
47class _ImmutableLineList(List[str]):
48 """
49 Some protection for our 'lines' list, which is assumed to be immutable in the cache.
50 (Useful for detecting obvious bugs.)
51 """
53 def _error(self, *a: object, **kw: object) -> NoReturn:
54 raise NotImplementedError("Attempt to modify an immutable list.")
56 __setitem__ = _error # type: ignore
57 append = _error
58 clear = _error
59 extend = _error
60 insert = _error
61 pop = _error
62 remove = _error
63 reverse = _error
64 sort = _error # type: ignore
67class _DocumentCache:
68 def __init__(self) -> None:
69 #: List of lines for the Document text.
70 self.lines: _ImmutableLineList | None = None
72 #: List of index positions, pointing to the start of all the lines.
73 self.line_indexes: list[int] | None = None
76class Document:
77 """
78 This is a immutable class around the text and cursor position, and contains
79 methods for querying this data, e.g. to give the text before the cursor.
81 This class is usually instantiated by a :class:`~prompt_toolkit.buffer.Buffer`
82 object, and accessed as the `document` property of that class.
84 :param text: string
85 :param cursor_position: int
86 :param selection: :class:`.SelectionState`
87 """
89 __slots__ = ("_text", "_cursor_position", "_selection", "_cache")
91 def __init__(
92 self,
93 text: str = "",
94 cursor_position: int | None = None,
95 selection: SelectionState | None = None,
96 ) -> None:
97 # Check cursor position. It can also be right after the end. (Where we
98 # insert text.)
99 assert cursor_position is None or cursor_position <= len(text), AssertionError(
100 f"cursor_position={cursor_position!r}, len_text={len(text)!r}"
101 )
103 # By default, if no cursor position was given, make sure to put the
104 # cursor position is at the end of the document. This is what makes
105 # sense in most places.
106 if cursor_position is None:
107 cursor_position = len(text)
109 # Keep these attributes private. A `Document` really has to be
110 # considered to be immutable, because otherwise the caching will break
111 # things. Because of that, we wrap these into read-only properties.
112 self._text = text
113 self._cursor_position = cursor_position
114 self._selection = selection
116 # Cache for lines/indexes. (Shared with other Document instances that
117 # contain the same text.
118 try:
119 self._cache = _text_to_document_cache[self.text]
120 except KeyError:
121 self._cache = _DocumentCache()
122 _text_to_document_cache[self.text] = self._cache
124 # XX: For some reason, above, we can't use 'WeakValueDictionary.setdefault'.
125 # This fails in Pypy3. `self._cache` becomes None, because that's what
126 # 'setdefault' returns.
127 # self._cache = _text_to_document_cache.setdefault(self.text, _DocumentCache())
128 # assert self._cache
130 def __repr__(self) -> str:
131 return f"{self.__class__.__name__}({self.text!r}, {self.cursor_position!r})"
133 def __eq__(self, other: object) -> bool:
134 if not isinstance(other, Document):
135 return False
137 return (
138 self.text == other.text
139 and self.cursor_position == other.cursor_position
140 and self.selection == other.selection
141 )
143 @property
144 def text(self) -> str:
145 "The document text."
146 return self._text
148 @property
149 def cursor_position(self) -> int:
150 "The document cursor position."
151 return self._cursor_position
153 @property
154 def selection(self) -> SelectionState | None:
155 ":class:`.SelectionState` object."
156 return self._selection
158 @property
159 def current_char(self) -> str:
160 """Return character under cursor or an empty string."""
161 return self._get_char_relative_to_cursor(0) or ""
163 @property
164 def char_before_cursor(self) -> str:
165 """Return character before the cursor or an empty string."""
166 return self._get_char_relative_to_cursor(-1) or ""
168 @property
169 def text_before_cursor(self) -> str:
170 return self.text[: self.cursor_position :]
172 @property
173 def text_after_cursor(self) -> str:
174 return self.text[self.cursor_position :]
176 @property
177 def current_line_before_cursor(self) -> str:
178 """Text from the start of the line until the cursor."""
179 _, _, text = self.text_before_cursor.rpartition("\n")
180 return text
182 @property
183 def current_line_after_cursor(self) -> str:
184 """Text from the cursor until the end of the line."""
185 text, _, _ = self.text_after_cursor.partition("\n")
186 return text
188 @property
189 def lines(self) -> list[str]:
190 """
191 Array of all the lines.
192 """
193 # Cache, because this one is reused very often.
194 if self._cache.lines is None:
195 self._cache.lines = _ImmutableLineList(self.text.split("\n"))
197 return self._cache.lines
199 @property
200 def _line_start_indexes(self) -> list[int]:
201 """
202 Array pointing to the start indexes of all the lines.
203 """
204 # Cache, because this is often reused. (If it is used, it's often used
205 # many times. And this has to be fast for editing big documents!)
206 if self._cache.line_indexes is None:
207 # Create list of line lengths.
208 line_lengths = map(len, self.lines)
210 # Calculate cumulative sums.
211 indexes = [0]
212 append = indexes.append
213 pos = 0
215 for line_length in line_lengths:
216 pos += line_length + 1
217 append(pos)
219 # Remove the last item. (This is not a new line.)
220 if len(indexes) > 1:
221 indexes.pop()
223 self._cache.line_indexes = indexes
225 return self._cache.line_indexes
227 @property
228 def lines_from_current(self) -> list[str]:
229 """
230 Array of the lines starting from the current line, until the last line.
231 """
232 return self.lines[self.cursor_position_row :]
234 @property
235 def line_count(self) -> int:
236 r"""Return the number of lines in this document. If the document ends
237 with a trailing \n, that counts as the beginning of a new line."""
238 return len(self.lines)
240 @property
241 def current_line(self) -> str:
242 """Return the text on the line where the cursor is. (when the input
243 consists of just one line, it equals `text`."""
244 return self.current_line_before_cursor + self.current_line_after_cursor
246 @property
247 def leading_whitespace_in_current_line(self) -> str:
248 """The leading whitespace in the left margin of the current line."""
249 current_line = self.current_line
250 length = len(current_line) - len(current_line.lstrip())
251 return current_line[:length]
253 def _get_char_relative_to_cursor(self, offset: int = 0) -> str:
254 """
255 Return character relative to cursor position, or empty string
256 """
257 try:
258 return self.text[self.cursor_position + offset]
259 except IndexError:
260 return ""
262 @property
263 def on_first_line(self) -> bool:
264 """
265 True when we are at the first line.
266 """
267 return self.cursor_position_row == 0
269 @property
270 def on_last_line(self) -> bool:
271 """
272 True when we are at the last line.
273 """
274 return self.cursor_position_row == self.line_count - 1
276 @property
277 def cursor_position_row(self) -> int:
278 """
279 Current row. (0-based.)
280 """
281 row, _ = self._find_line_start_index(self.cursor_position)
282 return row
284 @property
285 def cursor_position_col(self) -> int:
286 """
287 Current column. (0-based.)
288 """
289 # (Don't use self.text_before_cursor to calculate this. Creating
290 # substrings and doing rsplit is too expensive for getting the cursor
291 # position.)
292 _, line_start_index = self._find_line_start_index(self.cursor_position)
293 return self.cursor_position - line_start_index
295 def _find_line_start_index(self, index: int) -> tuple[int, int]:
296 """
297 For the index of a character at a certain line, calculate the index of
298 the first character on that line.
300 Return (row, index) tuple.
301 """
302 indexes = self._line_start_indexes
304 pos = bisect.bisect_right(indexes, index) - 1
305 return pos, indexes[pos]
307 def translate_index_to_position(self, index: int) -> tuple[int, int]:
308 """
309 Given an index for the text, return the corresponding (row, col) tuple.
310 (0-based. Returns (0, 0) for index=0.)
311 """
312 # Find start of this line.
313 row, row_index = self._find_line_start_index(index)
314 col = index - row_index
316 return row, col
318 def translate_row_col_to_index(self, row: int, col: int) -> int:
319 """
320 Given a (row, col) tuple, return the corresponding index.
321 (Row and col params are 0-based.)
323 Negative row/col values are turned into zero.
324 """
325 try:
326 result = self._line_start_indexes[row]
327 line = self.lines[row]
328 except IndexError:
329 if row < 0:
330 result = self._line_start_indexes[0]
331 line = self.lines[0]
332 else:
333 result = self._line_start_indexes[-1]
334 line = self.lines[-1]
336 result += max(0, min(col, len(line)))
338 # Keep in range. (len(self.text) is included, because the cursor can be
339 # right after the end of the text as well.)
340 result = max(0, min(result, len(self.text)))
341 return result
343 @property
344 def is_cursor_at_the_end(self) -> bool:
345 """True when the cursor is at the end of the text."""
346 return self.cursor_position == len(self.text)
348 @property
349 def is_cursor_at_the_end_of_line(self) -> bool:
350 """True when the cursor is at the end of this line."""
351 return self.current_char in ("\n", "")
353 def has_match_at_current_position(self, sub: str) -> bool:
354 """
355 `True` when this substring is found at the cursor position.
356 """
357 return self.text.find(sub, self.cursor_position) == self.cursor_position
359 def find(
360 self,
361 sub: str,
362 in_current_line: bool = False,
363 include_current_position: bool = False,
364 ignore_case: bool = False,
365 count: int = 1,
366 ) -> int | None:
367 """
368 Find `text` after the cursor, return position relative to the cursor
369 position. Return `None` if nothing was found.
371 :param count: Find the n-th occurrence.
372 """
373 assert isinstance(ignore_case, bool)
375 if in_current_line:
376 text = self.current_line_after_cursor
377 else:
378 text = self.text_after_cursor
380 if not include_current_position:
381 if len(text) == 0:
382 return None # (Otherwise, we always get a match for the empty string.)
383 else:
384 text = text[1:]
386 flags = re.IGNORECASE if ignore_case else 0
387 iterator = re.finditer(re.escape(sub), text, flags)
389 try:
390 for i, match in enumerate(iterator):
391 if i + 1 == count:
392 if include_current_position:
393 return match.start(0)
394 else:
395 return match.start(0) + 1
396 except StopIteration:
397 pass
398 return None
400 def find_all(self, sub: str, ignore_case: bool = False) -> list[int]:
401 """
402 Find all occurrences of the substring. Return a list of absolute
403 positions in the document.
404 """
405 flags = re.IGNORECASE if ignore_case else 0
406 return [a.start() for a in re.finditer(re.escape(sub), self.text, flags)]
408 def find_backwards(
409 self,
410 sub: str,
411 in_current_line: bool = False,
412 ignore_case: bool = False,
413 count: int = 1,
414 ) -> int | None:
415 """
416 Find `text` before the cursor, return position relative to the cursor
417 position. Return `None` if nothing was found.
419 :param count: Find the n-th occurrence.
420 """
421 if in_current_line:
422 before_cursor = self.current_line_before_cursor[::-1]
423 else:
424 before_cursor = self.text_before_cursor[::-1]
426 flags = re.IGNORECASE if ignore_case else 0
427 iterator = re.finditer(re.escape(sub[::-1]), before_cursor, flags)
429 try:
430 for i, match in enumerate(iterator):
431 if i + 1 == count:
432 return -match.start(0) - len(sub)
433 except StopIteration:
434 pass
435 return None
437 def get_word_before_cursor(
438 self, WORD: bool = False, pattern: Pattern[str] | None = None
439 ) -> str:
440 """
441 Give the word before the cursor.
442 If we have whitespace before the cursor this returns an empty string.
444 :param pattern: (None or compiled regex). When given, use this regex
445 pattern.
446 """
447 if self._is_word_before_cursor_complete(WORD=WORD, pattern=pattern):
448 # Space before the cursor or no text before cursor.
449 return ""
451 text_before_cursor = self.text_before_cursor
452 start = self.find_start_of_previous_word(WORD=WORD, pattern=pattern) or 0
454 return text_before_cursor[len(text_before_cursor) + start :]
456 def _is_word_before_cursor_complete(
457 self, WORD: bool = False, pattern: Pattern[str] | None = None
458 ) -> bool:
459 if pattern:
460 return self.find_start_of_previous_word(WORD=WORD, pattern=pattern) is None
461 else:
462 return (
463 self.text_before_cursor == "" or self.text_before_cursor[-1:].isspace()
464 )
466 def find_start_of_previous_word(
467 self, count: int = 1, WORD: bool = False, pattern: Pattern[str] | None = None
468 ) -> int | None:
469 """
470 Return an index relative to the cursor position pointing to the start
471 of the previous word. Return `None` if nothing was found.
473 :param pattern: (None or compiled regex). When given, use this regex
474 pattern.
475 """
476 assert not (WORD and pattern)
478 # Reverse the text before the cursor, in order to do an efficient
479 # backwards search.
480 text_before_cursor = self.text_before_cursor[::-1]
482 if pattern:
483 regex = pattern
484 elif WORD:
485 regex = _FIND_BIG_WORD_RE
486 else:
487 regex = _FIND_WORD_RE
489 iterator = regex.finditer(text_before_cursor)
491 try:
492 for i, match in enumerate(iterator):
493 if i + 1 == count:
494 return -match.end(0)
495 except StopIteration:
496 pass
497 return None
499 def find_boundaries_of_current_word(
500 self,
501 WORD: bool = False,
502 include_leading_whitespace: bool = False,
503 include_trailing_whitespace: bool = False,
504 ) -> tuple[int, int]:
505 """
506 Return the relative boundaries (startpos, endpos) of the current word under the
507 cursor. (This is at the current line, because line boundaries obviously
508 don't belong to any word.)
509 If not on a word, this returns (0,0)
510 """
511 text_before_cursor = self.current_line_before_cursor[::-1]
512 text_after_cursor = self.current_line_after_cursor
514 def get_regex(include_whitespace: bool) -> Pattern[str]:
515 return {
516 (False, False): _FIND_CURRENT_WORD_RE,
517 (False, True): _FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE,
518 (True, False): _FIND_CURRENT_BIG_WORD_RE,
519 (True, True): _FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE,
520 }[(WORD, include_whitespace)]
522 match_before = get_regex(include_leading_whitespace).search(text_before_cursor)
523 match_after = get_regex(include_trailing_whitespace).search(text_after_cursor)
525 # When there is a match before and after, and we're not looking for
526 # WORDs, make sure that both the part before and after the cursor are
527 # either in the [a-zA-Z_] alphabet or not. Otherwise, drop the part
528 # before the cursor.
529 if not WORD and match_before and match_after:
530 c1 = self.text[self.cursor_position - 1]
531 c2 = self.text[self.cursor_position]
532 alphabet = string.ascii_letters + "0123456789_"
534 if (c1 in alphabet) != (c2 in alphabet):
535 match_before = None
537 return (
538 -match_before.end(1) if match_before else 0,
539 match_after.end(1) if match_after else 0,
540 )
542 def get_word_under_cursor(self, WORD: bool = False) -> str:
543 """
544 Return the word, currently below the cursor.
545 This returns an empty string when the cursor is on a whitespace region.
546 """
547 start, end = self.find_boundaries_of_current_word(WORD=WORD)
548 return self.text[self.cursor_position + start : self.cursor_position + end]
550 def find_next_word_beginning(
551 self, count: int = 1, WORD: bool = False
552 ) -> int | None:
553 """
554 Return an index relative to the cursor position pointing to the start
555 of the next word. Return `None` if nothing was found.
556 """
557 if count < 0:
558 return self.find_previous_word_beginning(count=-count, WORD=WORD)
560 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
561 iterator = regex.finditer(self.text_after_cursor)
563 try:
564 for i, match in enumerate(iterator):
565 # Take first match, unless it's the word on which we're right now.
566 if i == 0 and match.start(1) == 0:
567 count += 1
569 if i + 1 == count:
570 return match.start(1)
571 except StopIteration:
572 pass
573 return None
575 def find_next_word_ending(
576 self, include_current_position: bool = False, count: int = 1, WORD: bool = False
577 ) -> int | None:
578 """
579 Return an index relative to the cursor position pointing to the end
580 of the next word. Return `None` if nothing was found.
581 """
582 if count < 0:
583 return self.find_previous_word_ending(count=-count, WORD=WORD)
585 if include_current_position:
586 text = self.text_after_cursor
587 else:
588 text = self.text_after_cursor[1:]
590 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
591 iterable = regex.finditer(text)
593 try:
594 for i, match in enumerate(iterable):
595 if i + 1 == count:
596 value = match.end(1)
598 if include_current_position:
599 return value
600 else:
601 return value + 1
603 except StopIteration:
604 pass
605 return None
607 def find_previous_word_beginning(
608 self, count: int = 1, WORD: bool = False
609 ) -> int | None:
610 """
611 Return an index relative to the cursor position pointing to the start
612 of the previous word. Return `None` if nothing was found.
613 """
614 if count < 0:
615 return self.find_next_word_beginning(count=-count, WORD=WORD)
617 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
618 iterator = regex.finditer(self.text_before_cursor[::-1])
620 try:
621 for i, match in enumerate(iterator):
622 if i + 1 == count:
623 return -match.end(1)
624 except StopIteration:
625 pass
626 return None
628 def find_previous_word_ending(
629 self, count: int = 1, WORD: bool = False
630 ) -> int | None:
631 """
632 Return an index relative to the cursor position pointing to the end
633 of the previous word. Return `None` if nothing was found.
634 """
635 if count < 0:
636 return self.find_next_word_ending(count=-count, WORD=WORD)
638 text_before_cursor = self.text_after_cursor[:1] + self.text_before_cursor[::-1]
640 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
641 iterator = regex.finditer(text_before_cursor)
643 try:
644 for i, match in enumerate(iterator):
645 # Take first match, unless it's the word on which we're right now.
646 if i == 0 and match.start(1) == 0:
647 count += 1
649 if i + 1 == count:
650 return -match.start(1) + 1
651 except StopIteration:
652 pass
653 return None
655 def find_next_matching_line(
656 self, match_func: Callable[[str], bool], count: int = 1
657 ) -> int | None:
658 """
659 Look downwards for empty lines.
660 Return the line index, relative to the current line.
661 """
662 result = None
664 for index, line in enumerate(self.lines[self.cursor_position_row + 1 :]):
665 if match_func(line):
666 result = 1 + index
667 count -= 1
669 if count == 0:
670 break
672 return result
674 def find_previous_matching_line(
675 self, match_func: Callable[[str], bool], count: int = 1
676 ) -> int | None:
677 """
678 Look upwards for empty lines.
679 Return the line index, relative to the current line.
680 """
681 result = None
683 for index, line in enumerate(self.lines[: self.cursor_position_row][::-1]):
684 if match_func(line):
685 result = -1 - index
686 count -= 1
688 if count == 0:
689 break
691 return result
693 def get_cursor_left_position(self, count: int = 1) -> int:
694 """
695 Relative position for cursor left.
696 """
697 if count < 0:
698 return self.get_cursor_right_position(-count)
700 return -min(self.cursor_position_col, count)
702 def get_cursor_right_position(self, count: int = 1) -> int:
703 """
704 Relative position for cursor_right.
705 """
706 if count < 0:
707 return self.get_cursor_left_position(-count)
709 return min(count, len(self.current_line_after_cursor))
711 def get_cursor_up_position(
712 self, count: int = 1, preferred_column: int | None = None
713 ) -> int:
714 """
715 Return the relative cursor position (character index) where we would be if the
716 user pressed the arrow-up button.
718 :param preferred_column: When given, go to this column instead of
719 staying at the current column.
720 """
721 assert count >= 1
722 column = (
723 self.cursor_position_col if preferred_column is None else preferred_column
724 )
726 return (
727 self.translate_row_col_to_index(
728 max(0, self.cursor_position_row - count), column
729 )
730 - self.cursor_position
731 )
733 def get_cursor_down_position(
734 self, count: int = 1, preferred_column: int | None = None
735 ) -> int:
736 """
737 Return the relative cursor position (character index) where we would be if the
738 user pressed the arrow-down button.
740 :param preferred_column: When given, go to this column instead of
741 staying at the current column.
742 """
743 assert count >= 1
744 column = (
745 self.cursor_position_col if preferred_column is None else preferred_column
746 )
748 return (
749 self.translate_row_col_to_index(self.cursor_position_row + count, column)
750 - self.cursor_position
751 )
753 def find_enclosing_bracket_right(
754 self, left_ch: str, right_ch: str, end_pos: int | None = None
755 ) -> int | None:
756 """
757 Find the right bracket enclosing current position. Return the relative
758 position to the cursor position.
760 When `end_pos` is given, don't look past the position.
761 """
762 if self.current_char == right_ch:
763 return 0
765 if end_pos is None:
766 end_pos = len(self.text)
767 else:
768 end_pos = min(len(self.text), end_pos)
770 stack = 1
772 # Look forward.
773 for i in range(self.cursor_position + 1, end_pos):
774 c = self.text[i]
776 if c == left_ch:
777 stack += 1
778 elif c == right_ch:
779 stack -= 1
781 if stack == 0:
782 return i - self.cursor_position
784 return None
786 def find_enclosing_bracket_left(
787 self, left_ch: str, right_ch: str, start_pos: int | None = None
788 ) -> int | None:
789 """
790 Find the left bracket enclosing current position. Return the relative
791 position to the cursor position.
793 When `start_pos` is given, don't look past the position.
794 """
795 if self.current_char == left_ch:
796 return 0
798 if start_pos is None:
799 start_pos = 0
800 else:
801 start_pos = max(0, start_pos)
803 stack = 1
805 # Look backward.
806 for i in range(self.cursor_position - 1, start_pos - 1, -1):
807 c = self.text[i]
809 if c == right_ch:
810 stack += 1
811 elif c == left_ch:
812 stack -= 1
814 if stack == 0:
815 return i - self.cursor_position
817 return None
819 def find_matching_bracket_position(
820 self, start_pos: int | None = None, end_pos: int | None = None
821 ) -> int:
822 """
823 Return relative cursor position of matching [, (, { or < bracket.
825 When `start_pos` or `end_pos` are given. Don't look past the positions.
826 """
828 # Look for a match.
829 for pair in "()", "[]", "{}", "<>":
830 A = pair[0]
831 B = pair[1]
832 if self.current_char == A:
833 return self.find_enclosing_bracket_right(A, B, end_pos=end_pos) or 0
834 elif self.current_char == B:
835 return self.find_enclosing_bracket_left(A, B, start_pos=start_pos) or 0
837 return 0
839 def get_start_of_document_position(self) -> int:
840 """Relative position for the start of the document."""
841 return -self.cursor_position
843 def get_end_of_document_position(self) -> int:
844 """Relative position for the end of the document."""
845 return len(self.text) - self.cursor_position
847 def get_start_of_line_position(self, after_whitespace: bool = False) -> int:
848 """Relative position for the start of this line."""
849 if after_whitespace:
850 current_line = self.current_line
851 return (
852 len(current_line)
853 - len(current_line.lstrip())
854 - self.cursor_position_col
855 )
856 else:
857 return -len(self.current_line_before_cursor)
859 def get_end_of_line_position(self) -> int:
860 """Relative position for the end of this line."""
861 return len(self.current_line_after_cursor)
863 def last_non_blank_of_current_line_position(self) -> int:
864 """
865 Relative position for the last non blank character of this line.
866 """
867 return len(self.current_line.rstrip()) - self.cursor_position_col - 1
869 def get_column_cursor_position(self, column: int) -> int:
870 """
871 Return the relative cursor position for this column at the current
872 line. (It will stay between the boundaries of the line in case of a
873 larger number.)
874 """
875 line_length = len(self.current_line)
876 current_column = self.cursor_position_col
877 column = max(0, min(line_length, column))
879 return column - current_column
881 def selection_range(
882 self,
883 ) -> tuple[
884 int, int
885 ]: # XXX: shouldn't this return `None` if there is no selection???
886 """
887 Return (from, to) tuple of the selection.
888 start and end position are included.
890 This doesn't take the selection type into account. Use
891 `selection_ranges` instead.
892 """
893 if self.selection:
894 from_, to = sorted(
895 [self.cursor_position, self.selection.original_cursor_position]
896 )
897 else:
898 from_, to = self.cursor_position, self.cursor_position
900 return from_, to
902 def selection_ranges(self) -> Iterable[tuple[int, int]]:
903 """
904 Return a list of `(from, to)` tuples for the selection or none if
905 nothing was selected. The upper boundary is not included.
907 This will yield several (from, to) tuples in case of a BLOCK selection.
908 This will return zero ranges, like (8,8) for empty lines in a block
909 selection.
910 """
911 if self.selection:
912 from_, to = sorted(
913 [self.cursor_position, self.selection.original_cursor_position]
914 )
916 if self.selection.type == SelectionType.BLOCK:
917 from_line, from_column = self.translate_index_to_position(from_)
918 to_line, to_column = self.translate_index_to_position(to)
919 from_column, to_column = sorted([from_column, to_column])
920 lines = self.lines
922 if vi_mode():
923 to_column += 1
925 for l in range(from_line, to_line + 1):
926 line_length = len(lines[l])
928 if from_column <= line_length:
929 yield (
930 self.translate_row_col_to_index(l, from_column),
931 self.translate_row_col_to_index(
932 l, min(line_length, to_column)
933 ),
934 )
935 else:
936 # In case of a LINES selection, go to the start/end of the lines.
937 if self.selection.type == SelectionType.LINES:
938 from_ = max(0, self.text.rfind("\n", 0, from_) + 1)
940 if self.text.find("\n", to) >= 0:
941 to = self.text.find("\n", to)
942 else:
943 to = len(self.text) - 1
945 # In Vi mode, the upper boundary is always included. For Emacs,
946 # that's not the case.
947 if vi_mode():
948 to += 1
950 yield from_, to
952 def selection_range_at_line(self, row: int) -> tuple[int, int] | None:
953 """
954 If the selection spans a portion of the given line, return a (from, to) tuple.
956 The returned upper boundary is not included in the selection, so
957 `(0, 0)` is an empty selection. `(0, 1)`, is a one character selection.
959 Returns None if the selection doesn't cover this line at all.
960 """
961 if self.selection:
962 line = self.lines[row]
964 row_start = self.translate_row_col_to_index(row, 0)
965 row_end = self.translate_row_col_to_index(row, len(line))
967 from_, to = sorted(
968 [self.cursor_position, self.selection.original_cursor_position]
969 )
971 # Take the intersection of the current line and the selection.
972 intersection_start = max(row_start, from_)
973 intersection_end = min(row_end, to)
975 if intersection_start <= intersection_end:
976 if self.selection.type == SelectionType.LINES:
977 intersection_start = row_start
978 intersection_end = row_end
980 elif self.selection.type == SelectionType.BLOCK:
981 _, col1 = self.translate_index_to_position(from_)
982 _, col2 = self.translate_index_to_position(to)
983 col1, col2 = sorted([col1, col2])
985 if col1 > len(line):
986 return None # Block selection doesn't cross this line.
988 intersection_start = self.translate_row_col_to_index(row, col1)
989 intersection_end = self.translate_row_col_to_index(row, col2)
991 _, from_column = self.translate_index_to_position(intersection_start)
992 _, to_column = self.translate_index_to_position(intersection_end)
994 # In Vi mode, the upper boundary is always included. For Emacs
995 # mode, that's not the case.
996 if vi_mode():
997 to_column += 1
999 return from_column, to_column
1000 return None
1002 def cut_selection(self) -> tuple[Document, ClipboardData]:
1003 """
1004 Return a (:class:`.Document`, :class:`.ClipboardData`) tuple, where the
1005 document represents the new document when the selection is cut, and the
1006 clipboard data, represents whatever has to be put on the clipboard.
1007 """
1008 if self.selection:
1009 cut_parts = []
1010 remaining_parts = []
1011 new_cursor_position = self.cursor_position
1013 last_to = 0
1014 for from_, to in self.selection_ranges():
1015 if last_to == 0:
1016 new_cursor_position = from_
1018 remaining_parts.append(self.text[last_to:from_])
1019 cut_parts.append(self.text[from_:to])
1020 last_to = to
1022 remaining_parts.append(self.text[last_to:])
1024 cut_text = "\n".join(cut_parts)
1025 remaining_text = "".join(remaining_parts)
1027 # In case of a LINES selection, don't include the trailing newline.
1028 if self.selection.type == SelectionType.LINES and cut_text.endswith("\n"):
1029 cut_text = cut_text[:-1]
1031 return (
1032 Document(text=remaining_text, cursor_position=new_cursor_position),
1033 ClipboardData(cut_text, self.selection.type),
1034 )
1035 else:
1036 return self, ClipboardData("")
1038 def paste_clipboard_data(
1039 self,
1040 data: ClipboardData,
1041 paste_mode: PasteMode = PasteMode.EMACS,
1042 count: int = 1,
1043 ) -> Document:
1044 """
1045 Return a new :class:`.Document` instance which contains the result if
1046 we would paste this data at the current cursor position.
1048 :param paste_mode: Where to paste. (Before/after/emacs.)
1049 :param count: When >1, Paste multiple times.
1050 """
1051 before = paste_mode == PasteMode.VI_BEFORE
1052 after = paste_mode == PasteMode.VI_AFTER
1054 if data.type == SelectionType.CHARACTERS:
1055 if after:
1056 new_text = (
1057 self.text[: self.cursor_position + 1]
1058 + data.text * count
1059 + self.text[self.cursor_position + 1 :]
1060 )
1061 else:
1062 new_text = (
1063 self.text_before_cursor + data.text * count + self.text_after_cursor
1064 )
1066 new_cursor_position = self.cursor_position + len(data.text) * count
1067 if before:
1068 new_cursor_position -= 1
1070 elif data.type == SelectionType.LINES:
1071 l = self.cursor_position_row
1072 if before:
1073 lines = self.lines[:l] + [data.text] * count + self.lines[l:]
1074 new_text = "\n".join(lines)
1075 new_cursor_position = len("".join(self.lines[:l])) + l
1076 else:
1077 lines = self.lines[: l + 1] + [data.text] * count + self.lines[l + 1 :]
1078 new_cursor_position = len("".join(self.lines[: l + 1])) + l + 1
1079 new_text = "\n".join(lines)
1081 elif data.type == SelectionType.BLOCK:
1082 lines = self.lines[:]
1083 start_line = self.cursor_position_row
1084 start_column = self.cursor_position_col + (0 if before else 1)
1086 for i, line in enumerate(data.text.split("\n")):
1087 index = i + start_line
1088 if index >= len(lines):
1089 lines.append("")
1091 lines[index] = lines[index].ljust(start_column)
1092 lines[index] = (
1093 lines[index][:start_column]
1094 + line * count
1095 + lines[index][start_column:]
1096 )
1098 new_text = "\n".join(lines)
1099 new_cursor_position = self.cursor_position + (0 if before else 1)
1101 return Document(text=new_text, cursor_position=new_cursor_position)
1103 def empty_line_count_at_the_end(self) -> int:
1104 """
1105 Return number of empty lines at the end of the document.
1106 """
1107 count = 0
1108 for line in self.lines[::-1]:
1109 if not line or line.isspace():
1110 count += 1
1111 else:
1112 break
1114 return count
1116 def start_of_paragraph(self, count: int = 1, before: bool = False) -> int:
1117 """
1118 Return the start of the current paragraph. (Relative cursor position.)
1119 """
1121 def match_func(text: str) -> bool:
1122 return not text or text.isspace()
1124 line_index = self.find_previous_matching_line(
1125 match_func=match_func, count=count
1126 )
1128 if line_index:
1129 add = 0 if before else 1
1130 return min(0, self.get_cursor_up_position(count=-line_index) + add)
1131 else:
1132 return -self.cursor_position
1134 def end_of_paragraph(self, count: int = 1, after: bool = False) -> int:
1135 """
1136 Return the end of the current paragraph. (Relative cursor position.)
1137 """
1139 def match_func(text: str) -> bool:
1140 return not text or text.isspace()
1142 line_index = self.find_next_matching_line(match_func=match_func, count=count)
1144 if line_index:
1145 add = 0 if after else 1
1146 return max(0, self.get_cursor_down_position(count=line_index) - add)
1147 else:
1148 return len(self.text_after_cursor)
1150 # Modifiers.
1152 def insert_after(self, text: str) -> Document:
1153 """
1154 Create a new document, with this text inserted after the buffer.
1155 It keeps selection ranges and cursor position in sync.
1156 """
1157 return Document(
1158 text=self.text + text,
1159 cursor_position=self.cursor_position,
1160 selection=self.selection,
1161 )
1163 def insert_before(self, text: str) -> Document:
1164 """
1165 Create a new document, with this text inserted before the buffer.
1166 It keeps selection ranges and cursor position in sync.
1167 """
1168 selection_state = self.selection
1170 if selection_state:
1171 selection_state = SelectionState(
1172 original_cursor_position=selection_state.original_cursor_position
1173 + len(text),
1174 type=selection_state.type,
1175 )
1177 return Document(
1178 text=text + self.text,
1179 cursor_position=self.cursor_position + len(text),
1180 selection=selection_state,
1181 )