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 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 return False
465
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.
472
473 :param pattern: (None or compiled regex). When given, use this regex
474 pattern.
475 """
476 assert not (WORD and pattern)
477
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]
481
482 if pattern:
483 regex = pattern
484 elif WORD:
485 regex = _FIND_BIG_WORD_RE
486 else:
487 regex = _FIND_WORD_RE
488
489 iterator = regex.finditer(text_before_cursor)
490
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
498
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
513
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)]
521
522 match_before = get_regex(include_leading_whitespace).search(text_before_cursor)
523 match_after = get_regex(include_trailing_whitespace).search(text_after_cursor)
524
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_"
533
534 if (c1 in alphabet) != (c2 in alphabet):
535 match_before = None
536
537 return (
538 -match_before.end(1) if match_before else 0,
539 match_after.end(1) if match_after else 0,
540 )
541
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]
549
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)
559
560 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
561 iterator = regex.finditer(self.text_after_cursor)
562
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
568
569 if i + 1 == count:
570 return match.start(1)
571 except StopIteration:
572 pass
573 return None
574
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)
584
585 if include_current_position:
586 text = self.text_after_cursor
587 else:
588 text = self.text_after_cursor[1:]
589
590 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
591 iterable = regex.finditer(text)
592
593 try:
594 for i, match in enumerate(iterable):
595 if i + 1 == count:
596 value = match.end(1)
597
598 if include_current_position:
599 return value
600 else:
601 return value + 1
602
603 except StopIteration:
604 pass
605 return None
606
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)
616
617 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
618 iterator = regex.finditer(self.text_before_cursor[::-1])
619
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
627
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)
637
638 text_before_cursor = self.text_after_cursor[:1] + self.text_before_cursor[::-1]
639
640 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
641 iterator = regex.finditer(text_before_cursor)
642
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
648
649 if i + 1 == count:
650 return -match.start(1) + 1
651 except StopIteration:
652 pass
653 return None
654
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
663
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
668
669 if count == 0:
670 break
671
672 return result
673
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
682
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
687
688 if count == 0:
689 break
690
691 return result
692
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)
699
700 return -min(self.cursor_position_col, count)
701
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)
708
709 return min(count, len(self.current_line_after_cursor))
710
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.
717
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 )
725
726 return (
727 self.translate_row_col_to_index(
728 max(0, self.cursor_position_row - count), column
729 )
730 - self.cursor_position
731 )
732
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.
739
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 )
747
748 return (
749 self.translate_row_col_to_index(self.cursor_position_row + count, column)
750 - self.cursor_position
751 )
752
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.
759
760 When `end_pos` is given, don't look past the position.
761 """
762 if self.current_char == right_ch:
763 return 0
764
765 if end_pos is None:
766 end_pos = len(self.text)
767 else:
768 end_pos = min(len(self.text), end_pos)
769
770 stack = 1
771
772 # Look forward.
773 for i in range(self.cursor_position + 1, end_pos):
774 c = self.text[i]
775
776 if c == left_ch:
777 stack += 1
778 elif c == right_ch:
779 stack -= 1
780
781 if stack == 0:
782 return i - self.cursor_position
783
784 return None
785
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.
792
793 When `start_pos` is given, don't look past the position.
794 """
795 if self.current_char == left_ch:
796 return 0
797
798 if start_pos is None:
799 start_pos = 0
800 else:
801 start_pos = max(0, start_pos)
802
803 stack = 1
804
805 # Look backward.
806 for i in range(self.cursor_position - 1, start_pos - 1, -1):
807 c = self.text[i]
808
809 if c == right_ch:
810 stack += 1
811 elif c == left_ch:
812 stack -= 1
813
814 if stack == 0:
815 return i - self.cursor_position
816
817 return None
818
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.
824
825 When `start_pos` or `end_pos` are given. Don't look past the positions.
826 """
827
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
836
837 return 0
838
839 def get_start_of_document_position(self) -> int:
840 """Relative position for the start of the document."""
841 return -self.cursor_position
842
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
846
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)
858
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)
862
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
868
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))
878
879 return column - current_column
880
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.
889
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
899
900 return from_, to
901
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.
906
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 )
915
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
921
922 if vi_mode():
923 to_column += 1
924
925 for l in range(from_line, to_line + 1):
926 line_length = len(lines[l])
927
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)
939
940 if self.text.find("\n", to) >= 0:
941 to = self.text.find("\n", to)
942 else:
943 to = len(self.text) - 1
944
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
949
950 yield from_, to
951
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.
955
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.
958
959 Returns None if the selection doesn't cover this line at all.
960 """
961 if self.selection:
962 line = self.lines[row]
963
964 row_start = self.translate_row_col_to_index(row, 0)
965 row_end = self.translate_row_col_to_index(row, len(line))
966
967 from_, to = sorted(
968 [self.cursor_position, self.selection.original_cursor_position]
969 )
970
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)
974
975 if intersection_start <= intersection_end:
976 if self.selection.type == SelectionType.LINES:
977 intersection_start = row_start
978 intersection_end = row_end
979
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])
984
985 if col1 > len(line):
986 return None # Block selection doesn't cross this line.
987
988 intersection_start = self.translate_row_col_to_index(row, col1)
989 intersection_end = self.translate_row_col_to_index(row, col2)
990
991 _, from_column = self.translate_index_to_position(intersection_start)
992 _, to_column = self.translate_index_to_position(intersection_end)
993
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
998
999 return from_column, to_column
1000 return None
1001
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
1012
1013 last_to = 0
1014 for from_, to in self.selection_ranges():
1015 if last_to == 0:
1016 new_cursor_position = from_
1017
1018 remaining_parts.append(self.text[last_to:from_])
1019 cut_parts.append(self.text[from_:to])
1020 last_to = to
1021
1022 remaining_parts.append(self.text[last_to:])
1023
1024 cut_text = "\n".join(cut_parts)
1025 remaining_text = "".join(remaining_parts)
1026
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]
1030
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("")
1037
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.
1047
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
1053
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 )
1065
1066 new_cursor_position = self.cursor_position + len(data.text) * count
1067 if before:
1068 new_cursor_position -= 1
1069
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)
1080
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)
1085
1086 for i, line in enumerate(data.text.split("\n")):
1087 index = i + start_line
1088 if index >= len(lines):
1089 lines.append("")
1090
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 )
1097
1098 new_text = "\n".join(lines)
1099 new_cursor_position = self.cursor_position + (0 if before else 1)
1100
1101 return Document(text=new_text, cursor_position=new_cursor_position)
1102
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
1113
1114 return count
1115
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 """
1120
1121 def match_func(text: str) -> bool:
1122 return not text or text.isspace()
1123
1124 line_index = self.find_previous_matching_line(
1125 match_func=match_func, count=count
1126 )
1127
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
1133
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 """
1138
1139 def match_func(text: str) -> bool:
1140 return not text or text.isspace()
1141
1142 line_index = self.find_next_matching_line(match_func=match_func, count=count)
1143
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)
1149
1150 # Modifiers.
1151
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 )
1162
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
1169
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 )
1176
1177 return Document(
1178 text=text + self.text,
1179 cursor_position=self.cursor_position + len(text),
1180 selection=selection_state,
1181 )