Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/prompt_toolkit/document.py: 23%

528 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-20 06:09 +0000

1""" 

2The `Document` that implements all the text operations/querying. 

3""" 

4from __future__ import annotations 

5 

6import bisect 

7import re 

8import string 

9import weakref 

10from typing import Callable, Dict, Iterable, List, NoReturn, Pattern, cast 

11 

12from .clipboard import ClipboardData 

13from .filters import vi_mode 

14from .selection import PasteMode, SelectionState, SelectionType 

15 

16__all__ = [ 

17 "Document", 

18] 

19 

20 

21# Regex for finding "words" in documents. (We consider a group of alnum 

22# characters a word, but also a group of special characters a word, as long as 

23# it doesn't contain a space.) 

24# (This is a 'word' in Vi.) 

25_FIND_WORD_RE = re.compile(r"([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)") 

26_FIND_CURRENT_WORD_RE = re.compile(r"^([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)") 

27_FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile( 

28 r"^(([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)\s*)" 

29) 

30 

31# Regex for finding "WORDS" in documents. 

32# (This is a 'WORD in Vi.) 

33_FIND_BIG_WORD_RE = re.compile(r"([^\s]+)") 

34_FIND_CURRENT_BIG_WORD_RE = re.compile(r"^([^\s]+)") 

35_FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile(r"^([^\s]+\s*)") 

36 

37# Share the Document._cache between all Document instances. 

38# (Document instances are considered immutable. That means that if another 

39# `Document` is constructed with the same text, it should have the same 

40# `_DocumentCache`.) 

41_text_to_document_cache: dict[str, _DocumentCache] = cast( 

42 Dict[str, "_DocumentCache"], 

43 weakref.WeakValueDictionary(), # Maps document.text to DocumentCache instance. 

44) 

45 

46 

47class _ImmutableLineList(List[str]): 

48 """ 

49 Some protection for our 'lines' list, which is assumed to be immutable in the cache. 

50 (Useful for detecting obvious bugs.) 

51 """ 

52 

53 def _error(self, *a: object, **kw: object) -> NoReturn: 

54 raise NotImplementedError("Attempt to modify an immutable list.") 

55 

56 __setitem__ = _error # type: ignore 

57 append = _error 

58 clear = _error 

59 extend = _error 

60 insert = _error 

61 pop = _error 

62 remove = _error 

63 reverse = _error 

64 sort = _error # type: ignore 

65 

66 

67class _DocumentCache: 

68 def __init__(self) -> None: 

69 #: List of lines for the Document text. 

70 self.lines: _ImmutableLineList | None = None 

71 

72 #: List of index positions, pointing to the start of all the lines. 

73 self.line_indexes: list[int] | None = None 

74 

75 

76class Document: 

77 """ 

78 This is a immutable class around the text and cursor position, and contains 

79 methods for querying this data, e.g. to give the text before the cursor. 

80 

81 This class is usually instantiated by a :class:`~prompt_toolkit.buffer.Buffer` 

82 object, and accessed as the `document` property of that class. 

83 

84 :param text: string 

85 :param cursor_position: int 

86 :param selection: :class:`.SelectionState` 

87 """ 

88 

89 __slots__ = ("_text", "_cursor_position", "_selection", "_cache") 

90 

91 def __init__( 

92 self, 

93 text: str = "", 

94 cursor_position: int | None = None, 

95 selection: SelectionState | None = None, 

96 ) -> None: 

97 # Check cursor position. It can also be right after the end. (Where we 

98 # insert text.) 

99 assert cursor_position is None or cursor_position <= len(text), AssertionError( 

100 f"cursor_position={cursor_position!r}, len_text={len(text)!r}" 

101 ) 

102 

103 # By default, if no cursor position was given, make sure to put the 

104 # cursor position is at the end of the document. This is what makes 

105 # sense in most places. 

106 if cursor_position is None: 

107 cursor_position = len(text) 

108 

109 # Keep these attributes private. A `Document` really has to be 

110 # considered to be immutable, because otherwise the caching will break 

111 # things. Because of that, we wrap these into read-only properties. 

112 self._text = text 

113 self._cursor_position = cursor_position 

114 self._selection = selection 

115 

116 # Cache for lines/indexes. (Shared with other Document instances that 

117 # contain the same text. 

118 try: 

119 self._cache = _text_to_document_cache[self.text] 

120 except KeyError: 

121 self._cache = _DocumentCache() 

122 _text_to_document_cache[self.text] = self._cache 

123 

124 # XX: For some reason, above, we can't use 'WeakValueDictionary.setdefault'. 

125 # This fails in Pypy3. `self._cache` becomes None, because that's what 

126 # 'setdefault' returns. 

127 # self._cache = _text_to_document_cache.setdefault(self.text, _DocumentCache()) 

128 # assert self._cache 

129 

130 def __repr__(self) -> str: 

131 return f"{self.__class__.__name__}({self.text!r}, {self.cursor_position!r})" 

132 

133 def __eq__(self, other: object) -> bool: 

134 if not isinstance(other, Document): 

135 return False 

136 

137 return ( 

138 self.text == other.text 

139 and self.cursor_position == other.cursor_position 

140 and self.selection == other.selection 

141 ) 

142 

143 @property 

144 def text(self) -> str: 

145 "The document text." 

146 return self._text 

147 

148 @property 

149 def cursor_position(self) -> int: 

150 "The document cursor position." 

151 return self._cursor_position 

152 

153 @property 

154 def selection(self) -> SelectionState | None: 

155 ":class:`.SelectionState` object." 

156 return self._selection 

157 

158 @property 

159 def current_char(self) -> str: 

160 """Return character under cursor or an empty string.""" 

161 return self._get_char_relative_to_cursor(0) or "" 

162 

163 @property 

164 def char_before_cursor(self) -> str: 

165 """Return character before the cursor or an empty string.""" 

166 return self._get_char_relative_to_cursor(-1) or "" 

167 

168 @property 

169 def text_before_cursor(self) -> str: 

170 return self.text[: self.cursor_position :] 

171 

172 @property 

173 def text_after_cursor(self) -> str: 

174 return self.text[self.cursor_position :] 

175 

176 @property 

177 def current_line_before_cursor(self) -> str: 

178 """Text from the start of the line until the cursor.""" 

179 _, _, text = self.text_before_cursor.rpartition("\n") 

180 return text 

181 

182 @property 

183 def current_line_after_cursor(self) -> str: 

184 """Text from the cursor until the end of the line.""" 

185 text, _, _ = self.text_after_cursor.partition("\n") 

186 return text 

187 

188 @property 

189 def lines(self) -> list[str]: 

190 """ 

191 Array of all the lines. 

192 """ 

193 # Cache, because this one is reused very often. 

194 if self._cache.lines is None: 

195 self._cache.lines = _ImmutableLineList(self.text.split("\n")) 

196 

197 return self._cache.lines 

198 

199 @property 

200 def _line_start_indexes(self) -> list[int]: 

201 """ 

202 Array pointing to the start indexes of all the lines. 

203 """ 

204 # Cache, because this is often reused. (If it is used, it's often used 

205 # many times. And this has to be fast for editing big documents!) 

206 if self._cache.line_indexes is None: 

207 # Create list of line lengths. 

208 line_lengths = map(len, self.lines) 

209 

210 # Calculate cumulative sums. 

211 indexes = [0] 

212 append = indexes.append 

213 pos = 0 

214 

215 for line_length in line_lengths: 

216 pos += line_length + 1 

217 append(pos) 

218 

219 # Remove the last item. (This is not a new line.) 

220 if len(indexes) > 1: 

221 indexes.pop() 

222 

223 self._cache.line_indexes = indexes 

224 

225 return self._cache.line_indexes 

226 

227 @property 

228 def lines_from_current(self) -> list[str]: 

229 """ 

230 Array of the lines starting from the current line, until the last line. 

231 """ 

232 return self.lines[self.cursor_position_row :] 

233 

234 @property 

235 def line_count(self) -> int: 

236 r"""Return the number of lines in this document. If the document ends 

237 with a trailing \n, that counts as the beginning of a new line.""" 

238 return len(self.lines) 

239 

240 @property 

241 def current_line(self) -> str: 

242 """Return the text on the line where the cursor is. (when the input 

243 consists of just one line, it equals `text`.""" 

244 return self.current_line_before_cursor + self.current_line_after_cursor 

245 

246 @property 

247 def leading_whitespace_in_current_line(self) -> str: 

248 """The leading whitespace in the left margin of the current line.""" 

249 current_line = self.current_line 

250 length = len(current_line) - len(current_line.lstrip()) 

251 return current_line[:length] 

252 

253 def _get_char_relative_to_cursor(self, offset: int = 0) -> str: 

254 """ 

255 Return character relative to cursor position, or empty string 

256 """ 

257 try: 

258 return self.text[self.cursor_position + offset] 

259 except IndexError: 

260 return "" 

261 

262 @property 

263 def on_first_line(self) -> bool: 

264 """ 

265 True when we are at the first line. 

266 """ 

267 return self.cursor_position_row == 0 

268 

269 @property 

270 def on_last_line(self) -> bool: 

271 """ 

272 True when we are at the last line. 

273 """ 

274 return self.cursor_position_row == self.line_count - 1 

275 

276 @property 

277 def cursor_position_row(self) -> int: 

278 """ 

279 Current row. (0-based.) 

280 """ 

281 row, _ = self._find_line_start_index(self.cursor_position) 

282 return row 

283 

284 @property 

285 def cursor_position_col(self) -> int: 

286 """ 

287 Current column. (0-based.) 

288 """ 

289 # (Don't use self.text_before_cursor to calculate this. Creating 

290 # substrings and doing rsplit is too expensive for getting the cursor 

291 # position.) 

292 _, line_start_index = self._find_line_start_index(self.cursor_position) 

293 return self.cursor_position - line_start_index 

294 

295 def _find_line_start_index(self, index: int) -> tuple[int, int]: 

296 """ 

297 For the index of a character at a certain line, calculate the index of 

298 the first character on that line. 

299 

300 Return (row, index) tuple. 

301 """ 

302 indexes = self._line_start_indexes 

303 

304 pos = bisect.bisect_right(indexes, index) - 1 

305 return pos, indexes[pos] 

306 

307 def translate_index_to_position(self, index: int) -> tuple[int, int]: 

308 """ 

309 Given an index for the text, return the corresponding (row, col) tuple. 

310 (0-based. Returns (0, 0) for index=0.) 

311 """ 

312 # Find start of this line. 

313 row, row_index = self._find_line_start_index(index) 

314 col = index - row_index 

315 

316 return row, col 

317 

318 def translate_row_col_to_index(self, row: int, col: int) -> int: 

319 """ 

320 Given a (row, col) tuple, return the corresponding index. 

321 (Row and col params are 0-based.) 

322 

323 Negative row/col values are turned into zero. 

324 """ 

325 try: 

326 result = self._line_start_indexes[row] 

327 line = self.lines[row] 

328 except IndexError: 

329 if row < 0: 

330 result = self._line_start_indexes[0] 

331 line = self.lines[0] 

332 else: 

333 result = self._line_start_indexes[-1] 

334 line = self.lines[-1] 

335 

336 result += max(0, min(col, len(line))) 

337 

338 # Keep in range. (len(self.text) is included, because the cursor can be 

339 # right after the end of the text as well.) 

340 result = max(0, min(result, len(self.text))) 

341 return result 

342 

343 @property 

344 def is_cursor_at_the_end(self) -> bool: 

345 """True when the cursor is at the end of the text.""" 

346 return self.cursor_position == len(self.text) 

347 

348 @property 

349 def is_cursor_at_the_end_of_line(self) -> bool: 

350 """True when the cursor is at the end of this line.""" 

351 return self.current_char in ("\n", "") 

352 

353 def has_match_at_current_position(self, sub: str) -> bool: 

354 """ 

355 `True` when this substring is found at the cursor position. 

356 """ 

357 return self.text.find(sub, self.cursor_position) == self.cursor_position 

358 

359 def find( 

360 self, 

361 sub: str, 

362 in_current_line: bool = False, 

363 include_current_position: bool = False, 

364 ignore_case: bool = False, 

365 count: int = 1, 

366 ) -> int | None: 

367 """ 

368 Find `text` after the cursor, return position relative to the cursor 

369 position. Return `None` if nothing was found. 

370 

371 :param count: Find the n-th occurrence. 

372 """ 

373 assert isinstance(ignore_case, bool) 

374 

375 if in_current_line: 

376 text = self.current_line_after_cursor 

377 else: 

378 text = self.text_after_cursor 

379 

380 if not include_current_position: 

381 if len(text) == 0: 

382 return None # (Otherwise, we always get a match for the empty string.) 

383 else: 

384 text = text[1:] 

385 

386 flags = re.IGNORECASE if ignore_case else 0 

387 iterator = re.finditer(re.escape(sub), text, flags) 

388 

389 try: 

390 for i, match in enumerate(iterator): 

391 if i + 1 == count: 

392 if include_current_position: 

393 return match.start(0) 

394 else: 

395 return match.start(0) + 1 

396 except StopIteration: 

397 pass 

398 return None 

399 

400 def find_all(self, sub: str, ignore_case: bool = False) -> list[int]: 

401 """ 

402 Find all occurrences of the substring. Return a list of absolute 

403 positions in the document. 

404 """ 

405 flags = re.IGNORECASE if ignore_case else 0 

406 return [a.start() for a in re.finditer(re.escape(sub), self.text, flags)] 

407 

408 def find_backwards( 

409 self, 

410 sub: str, 

411 in_current_line: bool = False, 

412 ignore_case: bool = False, 

413 count: int = 1, 

414 ) -> int | None: 

415 """ 

416 Find `text` before the cursor, return position relative to the cursor 

417 position. Return `None` if nothing was found. 

418 

419 :param count: Find the n-th occurrence. 

420 """ 

421 if in_current_line: 

422 before_cursor = self.current_line_before_cursor[::-1] 

423 else: 

424 before_cursor = self.text_before_cursor[::-1] 

425 

426 flags = re.IGNORECASE if ignore_case else 0 

427 iterator = re.finditer(re.escape(sub[::-1]), before_cursor, flags) 

428 

429 try: 

430 for i, match in enumerate(iterator): 

431 if i + 1 == count: 

432 return -match.start(0) - len(sub) 

433 except StopIteration: 

434 pass 

435 return None 

436 

437 def get_word_before_cursor( 

438 self, WORD: bool = False, pattern: Pattern[str] | None = None 

439 ) -> str: 

440 """ 

441 Give the word before the cursor. 

442 If we have whitespace before the cursor this returns an empty string. 

443 

444 :param pattern: (None or compiled regex). When given, use this regex 

445 pattern. 

446 """ 

447 if self._is_word_before_cursor_complete(WORD=WORD, pattern=pattern): 

448 # Space before the cursor or no text before cursor. 

449 return "" 

450 

451 text_before_cursor = self.text_before_cursor 

452 start = self.find_start_of_previous_word(WORD=WORD, pattern=pattern) or 0 

453 

454 return text_before_cursor[len(text_before_cursor) + start :] 

455 

456 def _is_word_before_cursor_complete( 

457 self, WORD: bool = False, pattern: Pattern[str] | None = None 

458 ) -> bool: 

459 if pattern: 

460 return self.find_start_of_previous_word(WORD=WORD, pattern=pattern) is None 

461 else: 

462 return ( 

463 self.text_before_cursor == "" or self.text_before_cursor[-1:].isspace() 

464 ) 

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 )