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 # type: ignore
58 append = _error
59 clear = _error
60 extend = _error
61 insert = _error
62 pop = _error
63 remove = _error
64 reverse = _error
65 sort = _error # type: ignore
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 pattern:
461 return self.find_start_of_previous_word(WORD=WORD, pattern=pattern) is None
462 else:
463 return (
464 self.text_before_cursor == "" or self.text_before_cursor[-1:].isspace()
465 )
466
467 def find_start_of_previous_word(
468 self, count: int = 1, WORD: bool = False, pattern: Pattern[str] | None = None
469 ) -> int | None:
470 """
471 Return an index relative to the cursor position pointing to the start
472 of the previous word. Return `None` if nothing was found.
473
474 :param pattern: (None or compiled regex). When given, use this regex
475 pattern.
476 """
477 assert not (WORD and pattern)
478
479 # Reverse the text before the cursor, in order to do an efficient
480 # backwards search.
481 text_before_cursor = self.text_before_cursor[::-1]
482
483 if pattern:
484 regex = pattern
485 elif WORD:
486 regex = _FIND_BIG_WORD_RE
487 else:
488 regex = _FIND_WORD_RE
489
490 iterator = regex.finditer(text_before_cursor)
491
492 try:
493 for i, match in enumerate(iterator):
494 if i + 1 == count:
495 return -match.end(0)
496 except StopIteration:
497 pass
498 return None
499
500 def find_boundaries_of_current_word(
501 self,
502 WORD: bool = False,
503 include_leading_whitespace: bool = False,
504 include_trailing_whitespace: bool = False,
505 ) -> tuple[int, int]:
506 """
507 Return the relative boundaries (startpos, endpos) of the current word under the
508 cursor. (This is at the current line, because line boundaries obviously
509 don't belong to any word.)
510 If not on a word, this returns (0,0)
511 """
512 text_before_cursor = self.current_line_before_cursor[::-1]
513 text_after_cursor = self.current_line_after_cursor
514
515 def get_regex(include_whitespace: bool) -> Pattern[str]:
516 return {
517 (False, False): _FIND_CURRENT_WORD_RE,
518 (False, True): _FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE,
519 (True, False): _FIND_CURRENT_BIG_WORD_RE,
520 (True, True): _FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE,
521 }[(WORD, include_whitespace)]
522
523 match_before = get_regex(include_leading_whitespace).search(text_before_cursor)
524 match_after = get_regex(include_trailing_whitespace).search(text_after_cursor)
525
526 # When there is a match before and after, and we're not looking for
527 # WORDs, make sure that both the part before and after the cursor are
528 # either in the [a-zA-Z_] alphabet or not. Otherwise, drop the part
529 # before the cursor.
530 if not WORD and match_before and match_after:
531 c1 = self.text[self.cursor_position - 1]
532 c2 = self.text[self.cursor_position]
533 alphabet = string.ascii_letters + "0123456789_"
534
535 if (c1 in alphabet) != (c2 in alphabet):
536 match_before = None
537
538 return (
539 -match_before.end(1) if match_before else 0,
540 match_after.end(1) if match_after else 0,
541 )
542
543 def get_word_under_cursor(self, WORD: bool = False) -> str:
544 """
545 Return the word, currently below the cursor.
546 This returns an empty string when the cursor is on a whitespace region.
547 """
548 start, end = self.find_boundaries_of_current_word(WORD=WORD)
549 return self.text[self.cursor_position + start : self.cursor_position + end]
550
551 def find_next_word_beginning(
552 self, count: int = 1, WORD: bool = False
553 ) -> int | None:
554 """
555 Return an index relative to the cursor position pointing to the start
556 of the next word. Return `None` if nothing was found.
557 """
558 if count < 0:
559 return self.find_previous_word_beginning(count=-count, WORD=WORD)
560
561 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
562 iterator = regex.finditer(self.text_after_cursor)
563
564 try:
565 for i, match in enumerate(iterator):
566 # Take first match, unless it's the word on which we're right now.
567 if i == 0 and match.start(1) == 0:
568 count += 1
569
570 if i + 1 == count:
571 return match.start(1)
572 except StopIteration:
573 pass
574 return None
575
576 def find_next_word_ending(
577 self, include_current_position: bool = False, count: int = 1, WORD: bool = False
578 ) -> int | None:
579 """
580 Return an index relative to the cursor position pointing to the end
581 of the next word. Return `None` if nothing was found.
582 """
583 if count < 0:
584 return self.find_previous_word_ending(count=-count, WORD=WORD)
585
586 if include_current_position:
587 text = self.text_after_cursor
588 else:
589 text = self.text_after_cursor[1:]
590
591 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
592 iterable = regex.finditer(text)
593
594 try:
595 for i, match in enumerate(iterable):
596 if i + 1 == count:
597 value = match.end(1)
598
599 if include_current_position:
600 return value
601 else:
602 return value + 1
603
604 except StopIteration:
605 pass
606 return None
607
608 def find_previous_word_beginning(
609 self, count: int = 1, WORD: bool = False
610 ) -> int | None:
611 """
612 Return an index relative to the cursor position pointing to the start
613 of the previous word. Return `None` if nothing was found.
614 """
615 if count < 0:
616 return self.find_next_word_beginning(count=-count, WORD=WORD)
617
618 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
619 iterator = regex.finditer(self.text_before_cursor[::-1])
620
621 try:
622 for i, match in enumerate(iterator):
623 if i + 1 == count:
624 return -match.end(1)
625 except StopIteration:
626 pass
627 return None
628
629 def find_previous_word_ending(
630 self, count: int = 1, WORD: bool = False
631 ) -> int | None:
632 """
633 Return an index relative to the cursor position pointing to the end
634 of the previous word. Return `None` if nothing was found.
635 """
636 if count < 0:
637 return self.find_next_word_ending(count=-count, WORD=WORD)
638
639 text_before_cursor = self.text_after_cursor[:1] + self.text_before_cursor[::-1]
640
641 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
642 iterator = regex.finditer(text_before_cursor)
643
644 try:
645 for i, match in enumerate(iterator):
646 # Take first match, unless it's the word on which we're right now.
647 if i == 0 and match.start(1) == 0:
648 count += 1
649
650 if i + 1 == count:
651 return -match.start(1) + 1
652 except StopIteration:
653 pass
654 return None
655
656 def find_next_matching_line(
657 self, match_func: Callable[[str], bool], count: int = 1
658 ) -> int | None:
659 """
660 Look downwards for empty lines.
661 Return the line index, relative to the current line.
662 """
663 result = None
664
665 for index, line in enumerate(self.lines[self.cursor_position_row + 1 :]):
666 if match_func(line):
667 result = 1 + index
668 count -= 1
669
670 if count == 0:
671 break
672
673 return result
674
675 def find_previous_matching_line(
676 self, match_func: Callable[[str], bool], count: int = 1
677 ) -> int | None:
678 """
679 Look upwards for empty lines.
680 Return the line index, relative to the current line.
681 """
682 result = None
683
684 for index, line in enumerate(self.lines[: self.cursor_position_row][::-1]):
685 if match_func(line):
686 result = -1 - index
687 count -= 1
688
689 if count == 0:
690 break
691
692 return result
693
694 def get_cursor_left_position(self, count: int = 1) -> int:
695 """
696 Relative position for cursor left.
697 """
698 if count < 0:
699 return self.get_cursor_right_position(-count)
700
701 return -min(self.cursor_position_col, count)
702
703 def get_cursor_right_position(self, count: int = 1) -> int:
704 """
705 Relative position for cursor_right.
706 """
707 if count < 0:
708 return self.get_cursor_left_position(-count)
709
710 return min(count, len(self.current_line_after_cursor))
711
712 def get_cursor_up_position(
713 self, count: int = 1, preferred_column: int | None = None
714 ) -> int:
715 """
716 Return the relative cursor position (character index) where we would be if the
717 user pressed the arrow-up button.
718
719 :param preferred_column: When given, go to this column instead of
720 staying at the current column.
721 """
722 assert count >= 1
723 column = (
724 self.cursor_position_col if preferred_column is None else preferred_column
725 )
726
727 return (
728 self.translate_row_col_to_index(
729 max(0, self.cursor_position_row - count), column
730 )
731 - self.cursor_position
732 )
733
734 def get_cursor_down_position(
735 self, count: int = 1, preferred_column: int | None = None
736 ) -> int:
737 """
738 Return the relative cursor position (character index) where we would be if the
739 user pressed the arrow-down button.
740
741 :param preferred_column: When given, go to this column instead of
742 staying at the current column.
743 """
744 assert count >= 1
745 column = (
746 self.cursor_position_col if preferred_column is None else preferred_column
747 )
748
749 return (
750 self.translate_row_col_to_index(self.cursor_position_row + count, column)
751 - self.cursor_position
752 )
753
754 def find_enclosing_bracket_right(
755 self, left_ch: str, right_ch: str, end_pos: int | None = None
756 ) -> int | None:
757 """
758 Find the right bracket enclosing current position. Return the relative
759 position to the cursor position.
760
761 When `end_pos` is given, don't look past the position.
762 """
763 if self.current_char == right_ch:
764 return 0
765
766 if end_pos is None:
767 end_pos = len(self.text)
768 else:
769 end_pos = min(len(self.text), end_pos)
770
771 stack = 1
772
773 # Look forward.
774 for i in range(self.cursor_position + 1, end_pos):
775 c = self.text[i]
776
777 if c == left_ch:
778 stack += 1
779 elif c == right_ch:
780 stack -= 1
781
782 if stack == 0:
783 return i - self.cursor_position
784
785 return None
786
787 def find_enclosing_bracket_left(
788 self, left_ch: str, right_ch: str, start_pos: int | None = None
789 ) -> int | None:
790 """
791 Find the left bracket enclosing current position. Return the relative
792 position to the cursor position.
793
794 When `start_pos` is given, don't look past the position.
795 """
796 if self.current_char == left_ch:
797 return 0
798
799 if start_pos is None:
800 start_pos = 0
801 else:
802 start_pos = max(0, start_pos)
803
804 stack = 1
805
806 # Look backward.
807 for i in range(self.cursor_position - 1, start_pos - 1, -1):
808 c = self.text[i]
809
810 if c == right_ch:
811 stack += 1
812 elif c == left_ch:
813 stack -= 1
814
815 if stack == 0:
816 return i - self.cursor_position
817
818 return None
819
820 def find_matching_bracket_position(
821 self, start_pos: int | None = None, end_pos: int | None = None
822 ) -> int:
823 """
824 Return relative cursor position of matching [, (, { or < bracket.
825
826 When `start_pos` or `end_pos` are given. Don't look past the positions.
827 """
828
829 # Look for a match.
830 for pair in "()", "[]", "{}", "<>":
831 A = pair[0]
832 B = pair[1]
833 if self.current_char == A:
834 return self.find_enclosing_bracket_right(A, B, end_pos=end_pos) or 0
835 elif self.current_char == B:
836 return self.find_enclosing_bracket_left(A, B, start_pos=start_pos) or 0
837
838 return 0
839
840 def get_start_of_document_position(self) -> int:
841 """Relative position for the start of the document."""
842 return -self.cursor_position
843
844 def get_end_of_document_position(self) -> int:
845 """Relative position for the end of the document."""
846 return len(self.text) - self.cursor_position
847
848 def get_start_of_line_position(self, after_whitespace: bool = False) -> int:
849 """Relative position for the start of this line."""
850 if after_whitespace:
851 current_line = self.current_line
852 return (
853 len(current_line)
854 - len(current_line.lstrip())
855 - self.cursor_position_col
856 )
857 else:
858 return -len(self.current_line_before_cursor)
859
860 def get_end_of_line_position(self) -> int:
861 """Relative position for the end of this line."""
862 return len(self.current_line_after_cursor)
863
864 def last_non_blank_of_current_line_position(self) -> int:
865 """
866 Relative position for the last non blank character of this line.
867 """
868 return len(self.current_line.rstrip()) - self.cursor_position_col - 1
869
870 def get_column_cursor_position(self, column: int) -> int:
871 """
872 Return the relative cursor position for this column at the current
873 line. (It will stay between the boundaries of the line in case of a
874 larger number.)
875 """
876 line_length = len(self.current_line)
877 current_column = self.cursor_position_col
878 column = max(0, min(line_length, column))
879
880 return column - current_column
881
882 def selection_range(
883 self,
884 ) -> tuple[
885 int, int
886 ]: # XXX: shouldn't this return `None` if there is no selection???
887 """
888 Return (from, to) tuple of the selection.
889 start and end position are included.
890
891 This doesn't take the selection type into account. Use
892 `selection_ranges` instead.
893 """
894 if self.selection:
895 from_, to = sorted(
896 [self.cursor_position, self.selection.original_cursor_position]
897 )
898 else:
899 from_, to = self.cursor_position, self.cursor_position
900
901 return from_, to
902
903 def selection_ranges(self) -> Iterable[tuple[int, int]]:
904 """
905 Return a list of `(from, to)` tuples for the selection or none if
906 nothing was selected. The upper boundary is not included.
907
908 This will yield several (from, to) tuples in case of a BLOCK selection.
909 This will return zero ranges, like (8,8) for empty lines in a block
910 selection.
911 """
912 if self.selection:
913 from_, to = sorted(
914 [self.cursor_position, self.selection.original_cursor_position]
915 )
916
917 if self.selection.type == SelectionType.BLOCK:
918 from_line, from_column = self.translate_index_to_position(from_)
919 to_line, to_column = self.translate_index_to_position(to)
920 from_column, to_column = sorted([from_column, to_column])
921 lines = self.lines
922
923 if vi_mode():
924 to_column += 1
925
926 for l in range(from_line, to_line + 1):
927 line_length = len(lines[l])
928
929 if from_column <= line_length:
930 yield (
931 self.translate_row_col_to_index(l, from_column),
932 self.translate_row_col_to_index(
933 l, min(line_length, to_column)
934 ),
935 )
936 else:
937 # In case of a LINES selection, go to the start/end of the lines.
938 if self.selection.type == SelectionType.LINES:
939 from_ = max(0, self.text.rfind("\n", 0, from_) + 1)
940
941 if self.text.find("\n", to) >= 0:
942 to = self.text.find("\n", to)
943 else:
944 to = len(self.text) - 1
945
946 # In Vi mode, the upper boundary is always included. For Emacs,
947 # that's not the case.
948 if vi_mode():
949 to += 1
950
951 yield from_, to
952
953 def selection_range_at_line(self, row: int) -> tuple[int, int] | None:
954 """
955 If the selection spans a portion of the given line, return a (from, to) tuple.
956
957 The returned upper boundary is not included in the selection, so
958 `(0, 0)` is an empty selection. `(0, 1)`, is a one character selection.
959
960 Returns None if the selection doesn't cover this line at all.
961 """
962 if self.selection:
963 line = self.lines[row]
964
965 row_start = self.translate_row_col_to_index(row, 0)
966 row_end = self.translate_row_col_to_index(row, len(line))
967
968 from_, to = sorted(
969 [self.cursor_position, self.selection.original_cursor_position]
970 )
971
972 # Take the intersection of the current line and the selection.
973 intersection_start = max(row_start, from_)
974 intersection_end = min(row_end, to)
975
976 if intersection_start <= intersection_end:
977 if self.selection.type == SelectionType.LINES:
978 intersection_start = row_start
979 intersection_end = row_end
980
981 elif self.selection.type == SelectionType.BLOCK:
982 _, col1 = self.translate_index_to_position(from_)
983 _, col2 = self.translate_index_to_position(to)
984 col1, col2 = sorted([col1, col2])
985
986 if col1 > len(line):
987 return None # Block selection doesn't cross this line.
988
989 intersection_start = self.translate_row_col_to_index(row, col1)
990 intersection_end = self.translate_row_col_to_index(row, col2)
991
992 _, from_column = self.translate_index_to_position(intersection_start)
993 _, to_column = self.translate_index_to_position(intersection_end)
994
995 # In Vi mode, the upper boundary is always included. For Emacs
996 # mode, that's not the case.
997 if vi_mode():
998 to_column += 1
999
1000 return from_column, to_column
1001 return None
1002
1003 def cut_selection(self) -> tuple[Document, ClipboardData]:
1004 """
1005 Return a (:class:`.Document`, :class:`.ClipboardData`) tuple, where the
1006 document represents the new document when the selection is cut, and the
1007 clipboard data, represents whatever has to be put on the clipboard.
1008 """
1009 if self.selection:
1010 cut_parts = []
1011 remaining_parts = []
1012 new_cursor_position = self.cursor_position
1013
1014 last_to = 0
1015 for from_, to in self.selection_ranges():
1016 if last_to == 0:
1017 new_cursor_position = from_
1018
1019 remaining_parts.append(self.text[last_to:from_])
1020 cut_parts.append(self.text[from_:to])
1021 last_to = to
1022
1023 remaining_parts.append(self.text[last_to:])
1024
1025 cut_text = "\n".join(cut_parts)
1026 remaining_text = "".join(remaining_parts)
1027
1028 # In case of a LINES selection, don't include the trailing newline.
1029 if self.selection.type == SelectionType.LINES and cut_text.endswith("\n"):
1030 cut_text = cut_text[:-1]
1031
1032 return (
1033 Document(text=remaining_text, cursor_position=new_cursor_position),
1034 ClipboardData(cut_text, self.selection.type),
1035 )
1036 else:
1037 return self, ClipboardData("")
1038
1039 def paste_clipboard_data(
1040 self,
1041 data: ClipboardData,
1042 paste_mode: PasteMode = PasteMode.EMACS,
1043 count: int = 1,
1044 ) -> Document:
1045 """
1046 Return a new :class:`.Document` instance which contains the result if
1047 we would paste this data at the current cursor position.
1048
1049 :param paste_mode: Where to paste. (Before/after/emacs.)
1050 :param count: When >1, Paste multiple times.
1051 """
1052 before = paste_mode == PasteMode.VI_BEFORE
1053 after = paste_mode == PasteMode.VI_AFTER
1054
1055 if data.type == SelectionType.CHARACTERS:
1056 if after:
1057 new_text = (
1058 self.text[: self.cursor_position + 1]
1059 + data.text * count
1060 + self.text[self.cursor_position + 1 :]
1061 )
1062 else:
1063 new_text = (
1064 self.text_before_cursor + data.text * count + self.text_after_cursor
1065 )
1066
1067 new_cursor_position = self.cursor_position + len(data.text) * count
1068 if before:
1069 new_cursor_position -= 1
1070
1071 elif data.type == SelectionType.LINES:
1072 l = self.cursor_position_row
1073 if before:
1074 lines = self.lines[:l] + [data.text] * count + self.lines[l:]
1075 new_text = "\n".join(lines)
1076 new_cursor_position = len("".join(self.lines[:l])) + l
1077 else:
1078 lines = self.lines[: l + 1] + [data.text] * count + self.lines[l + 1 :]
1079 new_cursor_position = len("".join(self.lines[: l + 1])) + l + 1
1080 new_text = "\n".join(lines)
1081
1082 elif data.type == SelectionType.BLOCK:
1083 lines = self.lines[:]
1084 start_line = self.cursor_position_row
1085 start_column = self.cursor_position_col + (0 if before else 1)
1086
1087 for i, line in enumerate(data.text.split("\n")):
1088 index = i + start_line
1089 if index >= len(lines):
1090 lines.append("")
1091
1092 lines[index] = lines[index].ljust(start_column)
1093 lines[index] = (
1094 lines[index][:start_column]
1095 + line * count
1096 + lines[index][start_column:]
1097 )
1098
1099 new_text = "\n".join(lines)
1100 new_cursor_position = self.cursor_position + (0 if before else 1)
1101
1102 return Document(text=new_text, cursor_position=new_cursor_position)
1103
1104 def empty_line_count_at_the_end(self) -> int:
1105 """
1106 Return number of empty lines at the end of the document.
1107 """
1108 count = 0
1109 for line in self.lines[::-1]:
1110 if not line or line.isspace():
1111 count += 1
1112 else:
1113 break
1114
1115 return count
1116
1117 def start_of_paragraph(self, count: int = 1, before: bool = False) -> int:
1118 """
1119 Return the start of the current paragraph. (Relative cursor position.)
1120 """
1121
1122 def match_func(text: str) -> bool:
1123 return not text or text.isspace()
1124
1125 line_index = self.find_previous_matching_line(
1126 match_func=match_func, count=count
1127 )
1128
1129 if line_index:
1130 add = 0 if before else 1
1131 return min(0, self.get_cursor_up_position(count=-line_index) + add)
1132 else:
1133 return -self.cursor_position
1134
1135 def end_of_paragraph(self, count: int = 1, after: bool = False) -> int:
1136 """
1137 Return the end of the current paragraph. (Relative cursor position.)
1138 """
1139
1140 def match_func(text: str) -> bool:
1141 return not text or text.isspace()
1142
1143 line_index = self.find_next_matching_line(match_func=match_func, count=count)
1144
1145 if line_index:
1146 add = 0 if after else 1
1147 return max(0, self.get_cursor_down_position(count=line_index) - add)
1148 else:
1149 return len(self.text_after_cursor)
1150
1151 # Modifiers.
1152
1153 def insert_after(self, text: str) -> Document:
1154 """
1155 Create a new document, with this text inserted after the buffer.
1156 It keeps selection ranges and cursor position in sync.
1157 """
1158 return Document(
1159 text=self.text + text,
1160 cursor_position=self.cursor_position,
1161 selection=self.selection,
1162 )
1163
1164 def insert_before(self, text: str) -> Document:
1165 """
1166 Create a new document, with this text inserted before the buffer.
1167 It keeps selection ranges and cursor position in sync.
1168 """
1169 selection_state = self.selection
1170
1171 if selection_state:
1172 selection_state = SelectionState(
1173 original_cursor_position=selection_state.original_cursor_position
1174 + len(text),
1175 type=selection_state.type,
1176 )
1177
1178 return Document(
1179 text=text + self.text,
1180 cursor_position=self.cursor_position + len(text),
1181 selection=selection_state,
1182 )