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