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

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

533 statements  

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 collections.abc import Callable, Iterable 

12from re import Pattern 

13from typing import NoReturn, cast 

14 

15from .clipboard import ClipboardData 

16from .filters import vi_mode 

17from .selection import PasteMode, SelectionState, SelectionType 

18 

19__all__ = [ 

20 "Document", 

21] 

22 

23 

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

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

26# it doesn't contain a space.) 

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

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

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

30_FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile( 

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

32) 

33 

34# Regex for finding "WORDS" in documents. 

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

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

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

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

39 

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

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

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

43# `_DocumentCache`.) 

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

45 dict[str, "_DocumentCache"], 

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

47) 

48 

49 

50class _ImmutableLineList(list[str]): 

51 """ 

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

53 (Useful for detecting obvious bugs.) 

54 """ 

55 

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

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

58 

59 __setitem__ = _error 

60 append = _error 

61 clear = _error 

62 extend = _error 

63 insert = _error 

64 pop = _error 

65 remove = _error 

66 reverse = _error 

67 sort = _error 

68 

69 

70class _DocumentCache: 

71 def __init__(self) -> None: 

72 #: List of lines for the Document text. 

73 self.lines: _ImmutableLineList | None = None 

74 

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

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

77 

78 

79class Document: 

80 """ 

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

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

83 

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

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

86 

87 :param text: string 

88 :param cursor_position: int 

89 :param selection: :class:`.SelectionState` 

90 """ 

91 

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

93 

94 def __init__( 

95 self, 

96 text: str = "", 

97 cursor_position: int | None = None, 

98 selection: SelectionState | None = None, 

99 ) -> None: 

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

101 # insert text.) 

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

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

104 ) 

105 

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

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

108 # sense in most places. 

109 if cursor_position is None: 

110 cursor_position = len(text) 

111 

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

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

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

115 self._text = text 

116 self._cursor_position = cursor_position 

117 self._selection = selection 

118 

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

120 # contain the same text. 

121 try: 

122 self._cache = _text_to_document_cache[self.text] 

123 except KeyError: 

124 self._cache = _DocumentCache() 

125 _text_to_document_cache[self.text] = self._cache 

126 

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

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

129 # 'setdefault' returns. 

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

131 # assert self._cache 

132 

133 def __repr__(self) -> str: 

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

135 

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

137 if not isinstance(other, Document): 

138 return False 

139 

140 return ( 

141 self.text == other.text 

142 and self.cursor_position == other.cursor_position 

143 and self.selection == other.selection 

144 ) 

145 

146 @property 

147 def text(self) -> str: 

148 "The document text." 

149 return self._text 

150 

151 @property 

152 def cursor_position(self) -> int: 

153 "The document cursor position." 

154 return self._cursor_position 

155 

156 @property 

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

158 ":class:`.SelectionState` object." 

159 return self._selection 

160 

161 @property 

162 def current_char(self) -> str: 

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

164 return self._get_char_relative_to_cursor(0) or "" 

165 

166 @property 

167 def char_before_cursor(self) -> str: 

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

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

170 

171 @property 

172 def text_before_cursor(self) -> str: 

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

174 

175 @property 

176 def text_after_cursor(self) -> str: 

177 return self.text[self.cursor_position :] 

178 

179 @property 

180 def current_line_before_cursor(self) -> str: 

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

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

183 return text 

184 

185 @property 

186 def current_line_after_cursor(self) -> str: 

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

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

189 return text 

190 

191 @property 

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

193 """ 

194 Array of all the lines. 

195 """ 

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

197 if self._cache.lines is None: 

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

199 

200 return self._cache.lines 

201 

202 @property 

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

204 """ 

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

206 """ 

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

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

209 if self._cache.line_indexes is None: 

210 # Create list of line lengths. 

211 line_lengths = map(len, self.lines) 

212 

213 # Calculate cumulative sums. 

214 indexes = [0] 

215 append = indexes.append 

216 pos = 0 

217 

218 for line_length in line_lengths: 

219 pos += line_length + 1 

220 append(pos) 

221 

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

223 if len(indexes) > 1: 

224 indexes.pop() 

225 

226 self._cache.line_indexes = indexes 

227 

228 return self._cache.line_indexes 

229 

230 @property 

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

232 """ 

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

234 """ 

235 return self.lines[self.cursor_position_row :] 

236 

237 @property 

238 def line_count(self) -> int: 

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

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

241 return len(self.lines) 

242 

243 @property 

244 def current_line(self) -> str: 

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

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

247 return self.current_line_before_cursor + self.current_line_after_cursor 

248 

249 @property 

250 def leading_whitespace_in_current_line(self) -> str: 

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

252 current_line = self.current_line 

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

254 return current_line[:length] 

255 

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

257 """ 

258 Return character relative to cursor position, or empty string 

259 """ 

260 try: 

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

262 except IndexError: 

263 return "" 

264 

265 @property 

266 def on_first_line(self) -> bool: 

267 """ 

268 True when we are at the first line. 

269 """ 

270 return self.cursor_position_row == 0 

271 

272 @property 

273 def on_last_line(self) -> bool: 

274 """ 

275 True when we are at the last line. 

276 """ 

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

278 

279 @property 

280 def cursor_position_row(self) -> int: 

281 """ 

282 Current row. (0-based.) 

283 """ 

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

285 return row 

286 

287 @property 

288 def cursor_position_col(self) -> int: 

289 """ 

290 Current column. (0-based.) 

291 """ 

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

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

294 # position.) 

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

296 return self.cursor_position - line_start_index 

297 

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

299 """ 

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

301 the first character on that line. 

302 

303 Return (row, index) tuple. 

304 """ 

305 indexes = self._line_start_indexes 

306 

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

308 return pos, indexes[pos] 

309 

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

311 """ 

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

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

314 """ 

315 # Find start of this line. 

316 row, row_index = self._find_line_start_index(index) 

317 col = index - row_index 

318 

319 return row, col 

320 

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

322 """ 

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

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

325 

326 Negative row/col values are turned into zero. 

327 """ 

328 try: 

329 result = self._line_start_indexes[row] 

330 line = self.lines[row] 

331 except IndexError: 

332 if row < 0: 

333 result = self._line_start_indexes[0] 

334 line = self.lines[0] 

335 else: 

336 result = self._line_start_indexes[-1] 

337 line = self.lines[-1] 

338 

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

340 

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

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

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

344 return result 

345 

346 @property 

347 def is_cursor_at_the_end(self) -> bool: 

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

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

350 

351 @property 

352 def is_cursor_at_the_end_of_line(self) -> bool: 

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

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

355 

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

357 """ 

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

359 """ 

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

361 

362 def find( 

363 self, 

364 sub: str, 

365 in_current_line: bool = False, 

366 include_current_position: bool = False, 

367 ignore_case: bool = False, 

368 count: int = 1, 

369 ) -> int | None: 

370 """ 

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

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

373 

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

375 """ 

376 assert isinstance(ignore_case, bool) 

377 

378 if in_current_line: 

379 text = self.current_line_after_cursor 

380 else: 

381 text = self.text_after_cursor 

382 

383 if not include_current_position: 

384 if len(text) == 0: 

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

386 else: 

387 text = text[1:] 

388 

389 flags = re.IGNORECASE if ignore_case else 0 

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

391 

392 try: 

393 for i, match in enumerate(iterator): 

394 if i + 1 == count: 

395 if include_current_position: 

396 return match.start(0) 

397 else: 

398 return match.start(0) + 1 

399 except StopIteration: 

400 pass 

401 return None 

402 

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

404 """ 

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

406 positions in the document. 

407 """ 

408 flags = re.IGNORECASE if ignore_case else 0 

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

410 

411 def find_backwards( 

412 self, 

413 sub: str, 

414 in_current_line: bool = False, 

415 ignore_case: bool = False, 

416 count: int = 1, 

417 ) -> int | None: 

418 """ 

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

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

421 

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

423 """ 

424 if in_current_line: 

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

426 else: 

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

428 

429 flags = re.IGNORECASE if ignore_case else 0 

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

431 

432 try: 

433 for i, match in enumerate(iterator): 

434 if i + 1 == count: 

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

436 except StopIteration: 

437 pass 

438 return None 

439 

440 def get_word_before_cursor( 

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

442 ) -> str: 

443 """ 

444 Give the word before the cursor. 

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

446 

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

448 pattern. 

449 """ 

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

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

452 return "" 

453 

454 text_before_cursor = self.text_before_cursor 

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

456 

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

458 

459 def _is_word_before_cursor_complete( 

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

461 ) -> bool: 

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

463 return True 

464 if pattern: 

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

466 return False 

467 

468 def find_start_of_previous_word( 

469 self, count: int = 1, WORD: bool = False, pattern: Pattern[str] | None = None 

470 ) -> int | None: 

471 """ 

472 Return an index relative to the cursor position pointing to the start 

473 of the previous word. Return `None` if nothing was found. 

474 

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

476 pattern. 

477 """ 

478 assert not (WORD and pattern) 

479 

480 # Reverse the text before the cursor, in order to do an efficient 

481 # backwards search. 

482 text_before_cursor = self.text_before_cursor[::-1] 

483 

484 if pattern: 

485 regex = pattern 

486 elif WORD: 

487 regex = _FIND_BIG_WORD_RE 

488 else: 

489 regex = _FIND_WORD_RE 

490 

491 iterator = regex.finditer(text_before_cursor) 

492 

493 try: 

494 for i, match in enumerate(iterator): 

495 if i + 1 == count: 

496 return -match.end(0) 

497 except StopIteration: 

498 pass 

499 return None 

500 

501 def find_boundaries_of_current_word( 

502 self, 

503 WORD: bool = False, 

504 include_leading_whitespace: bool = False, 

505 include_trailing_whitespace: bool = False, 

506 ) -> tuple[int, int]: 

507 """ 

508 Return the relative boundaries (startpos, endpos) of the current word under the 

509 cursor. (This is at the current line, because line boundaries obviously 

510 don't belong to any word.) 

511 If not on a word, this returns (0,0) 

512 """ 

513 text_before_cursor = self.current_line_before_cursor[::-1] 

514 text_after_cursor = self.current_line_after_cursor 

515 

516 def get_regex(include_whitespace: bool) -> Pattern[str]: 

517 return { 

518 (False, False): _FIND_CURRENT_WORD_RE, 

519 (False, True): _FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE, 

520 (True, False): _FIND_CURRENT_BIG_WORD_RE, 

521 (True, True): _FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE, 

522 }[(WORD, include_whitespace)] 

523 

524 match_before = get_regex(include_leading_whitespace).search(text_before_cursor) 

525 match_after = get_regex(include_trailing_whitespace).search(text_after_cursor) 

526 

527 # When there is a match before and after, and we're not looking for 

528 # WORDs, make sure that both the part before and after the cursor are 

529 # either in the [a-zA-Z_] alphabet or not. Otherwise, drop the part 

530 # before the cursor. 

531 if not WORD and match_before and match_after: 

532 c1 = self.text[self.cursor_position - 1] 

533 c2 = self.text[self.cursor_position] 

534 alphabet = string.ascii_letters + "0123456789_" 

535 

536 if (c1 in alphabet) != (c2 in alphabet): 

537 match_before = None 

538 

539 return ( 

540 -match_before.end(1) if match_before else 0, 

541 match_after.end(1) if match_after else 0, 

542 ) 

543 

544 def get_word_under_cursor(self, WORD: bool = False) -> str: 

545 """ 

546 Return the word, currently below the cursor. 

547 This returns an empty string when the cursor is on a whitespace region. 

548 """ 

549 start, end = self.find_boundaries_of_current_word(WORD=WORD) 

550 return self.text[self.cursor_position + start : self.cursor_position + end] 

551 

552 def find_next_word_beginning( 

553 self, count: int = 1, WORD: bool = False 

554 ) -> int | None: 

555 """ 

556 Return an index relative to the cursor position pointing to the start 

557 of the next word. Return `None` if nothing was found. 

558 """ 

559 if count < 0: 

560 return self.find_previous_word_beginning(count=-count, WORD=WORD) 

561 

562 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

563 iterator = regex.finditer(self.text_after_cursor) 

564 

565 try: 

566 for i, match in enumerate(iterator): 

567 # Take first match, unless it's the word on which we're right now. 

568 if i == 0 and match.start(1) == 0: 

569 count += 1 

570 

571 if i + 1 == count: 

572 return match.start(1) 

573 except StopIteration: 

574 pass 

575 return None 

576 

577 def find_next_word_ending( 

578 self, include_current_position: bool = False, count: int = 1, WORD: bool = False 

579 ) -> int | None: 

580 """ 

581 Return an index relative to the cursor position pointing to the end 

582 of the next word. Return `None` if nothing was found. 

583 """ 

584 if count < 0: 

585 return self.find_previous_word_ending(count=-count, WORD=WORD) 

586 

587 if include_current_position: 

588 text = self.text_after_cursor 

589 else: 

590 text = self.text_after_cursor[1:] 

591 

592 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

593 iterable = regex.finditer(text) 

594 

595 try: 

596 for i, match in enumerate(iterable): 

597 if i + 1 == count: 

598 value = match.end(1) 

599 

600 if include_current_position: 

601 return value 

602 else: 

603 return value + 1 

604 

605 except StopIteration: 

606 pass 

607 return None 

608 

609 def find_previous_word_beginning( 

610 self, count: int = 1, WORD: bool = False 

611 ) -> int | None: 

612 """ 

613 Return an index relative to the cursor position pointing to the start 

614 of the previous word. Return `None` if nothing was found. 

615 """ 

616 if count < 0: 

617 return self.find_next_word_beginning(count=-count, WORD=WORD) 

618 

619 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

620 iterator = regex.finditer(self.text_before_cursor[::-1]) 

621 

622 try: 

623 for i, match in enumerate(iterator): 

624 if i + 1 == count: 

625 return -match.end(1) 

626 except StopIteration: 

627 pass 

628 return None 

629 

630 def find_previous_word_ending( 

631 self, count: int = 1, WORD: bool = False 

632 ) -> int | None: 

633 """ 

634 Return an index relative to the cursor position pointing to the end 

635 of the previous word. Return `None` if nothing was found. 

636 """ 

637 if count < 0: 

638 return self.find_next_word_ending(count=-count, WORD=WORD) 

639 

640 text_before_cursor = self.text_after_cursor[:1] + self.text_before_cursor[::-1] 

641 

642 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

643 iterator = regex.finditer(text_before_cursor) 

644 

645 try: 

646 for i, match in enumerate(iterator): 

647 # Take first match, unless it's the word on which we're right now. 

648 if i == 0 and match.start(1) == 0: 

649 count += 1 

650 

651 if i + 1 == count: 

652 return -match.start(1) + 1 

653 except StopIteration: 

654 pass 

655 return None 

656 

657 def find_next_matching_line( 

658 self, match_func: Callable[[str], bool], count: int = 1 

659 ) -> int | None: 

660 """ 

661 Look downwards for empty lines. 

662 Return the line index, relative to the current line. 

663 """ 

664 result = None 

665 

666 for index, line in enumerate(self.lines[self.cursor_position_row + 1 :]): 

667 if match_func(line): 

668 result = 1 + index 

669 count -= 1 

670 

671 if count == 0: 

672 break 

673 

674 return result 

675 

676 def find_previous_matching_line( 

677 self, match_func: Callable[[str], bool], count: int = 1 

678 ) -> int | None: 

679 """ 

680 Look upwards for empty lines. 

681 Return the line index, relative to the current line. 

682 """ 

683 result = None 

684 

685 for index, line in enumerate(self.lines[: self.cursor_position_row][::-1]): 

686 if match_func(line): 

687 result = -1 - index 

688 count -= 1 

689 

690 if count == 0: 

691 break 

692 

693 return result 

694 

695 def get_cursor_left_position(self, count: int = 1) -> int: 

696 """ 

697 Relative position for cursor left. 

698 """ 

699 if count < 0: 

700 return self.get_cursor_right_position(-count) 

701 

702 return -min(self.cursor_position_col, count) 

703 

704 def get_cursor_right_position(self, count: int = 1) -> int: 

705 """ 

706 Relative position for cursor_right. 

707 """ 

708 if count < 0: 

709 return self.get_cursor_left_position(-count) 

710 

711 return min(count, len(self.current_line_after_cursor)) 

712 

713 def get_cursor_up_position( 

714 self, count: int = 1, preferred_column: int | None = None 

715 ) -> int: 

716 """ 

717 Return the relative cursor position (character index) where we would be if the 

718 user pressed the arrow-up button. 

719 

720 :param preferred_column: When given, go to this column instead of 

721 staying at the current column. 

722 """ 

723 assert count >= 1 

724 column = ( 

725 self.cursor_position_col if preferred_column is None else preferred_column 

726 ) 

727 

728 return ( 

729 self.translate_row_col_to_index( 

730 max(0, self.cursor_position_row - count), column 

731 ) 

732 - self.cursor_position 

733 ) 

734 

735 def get_cursor_down_position( 

736 self, count: int = 1, preferred_column: int | None = None 

737 ) -> int: 

738 """ 

739 Return the relative cursor position (character index) where we would be if the 

740 user pressed the arrow-down button. 

741 

742 :param preferred_column: When given, go to this column instead of 

743 staying at the current column. 

744 """ 

745 assert count >= 1 

746 column = ( 

747 self.cursor_position_col if preferred_column is None else preferred_column 

748 ) 

749 

750 return ( 

751 self.translate_row_col_to_index(self.cursor_position_row + count, column) 

752 - self.cursor_position 

753 ) 

754 

755 def find_enclosing_bracket_right( 

756 self, left_ch: str, right_ch: str, end_pos: int | None = None 

757 ) -> int | None: 

758 """ 

759 Find the right bracket enclosing current position. Return the relative 

760 position to the cursor position. 

761 

762 When `end_pos` is given, don't look past the position. 

763 """ 

764 if self.current_char == right_ch: 

765 return 0 

766 

767 if end_pos is None: 

768 end_pos = len(self.text) 

769 else: 

770 end_pos = min(len(self.text), end_pos) 

771 

772 stack = 1 

773 

774 # Look forward. 

775 for i in range(self.cursor_position + 1, end_pos): 

776 c = self.text[i] 

777 

778 if c == left_ch: 

779 stack += 1 

780 elif c == right_ch: 

781 stack -= 1 

782 

783 if stack == 0: 

784 return i - self.cursor_position 

785 

786 return None 

787 

788 def find_enclosing_bracket_left( 

789 self, left_ch: str, right_ch: str, start_pos: int | None = None 

790 ) -> int | None: 

791 """ 

792 Find the left bracket enclosing current position. Return the relative 

793 position to the cursor position. 

794 

795 When `start_pos` is given, don't look past the position. 

796 """ 

797 if self.current_char == left_ch: 

798 return 0 

799 

800 if start_pos is None: 

801 start_pos = 0 

802 else: 

803 start_pos = max(0, start_pos) 

804 

805 stack = 1 

806 

807 # Look backward. 

808 for i in range(self.cursor_position - 1, start_pos - 1, -1): 

809 c = self.text[i] 

810 

811 if c == right_ch: 

812 stack += 1 

813 elif c == left_ch: 

814 stack -= 1 

815 

816 if stack == 0: 

817 return i - self.cursor_position 

818 

819 return None 

820 

821 def find_matching_bracket_position( 

822 self, start_pos: int | None = None, end_pos: int | None = None 

823 ) -> int: 

824 """ 

825 Return relative cursor position of matching [, (, { or < bracket. 

826 

827 When `start_pos` or `end_pos` are given. Don't look past the positions. 

828 """ 

829 

830 # Look for a match. 

831 for pair in "()", "[]", "{}", "<>": 

832 A = pair[0] 

833 B = pair[1] 

834 if self.current_char == A: 

835 return self.find_enclosing_bracket_right(A, B, end_pos=end_pos) or 0 

836 elif self.current_char == B: 

837 return self.find_enclosing_bracket_left(A, B, start_pos=start_pos) or 0 

838 

839 return 0 

840 

841 def get_start_of_document_position(self) -> int: 

842 """Relative position for the start of the document.""" 

843 return -self.cursor_position 

844 

845 def get_end_of_document_position(self) -> int: 

846 """Relative position for the end of the document.""" 

847 return len(self.text) - self.cursor_position 

848 

849 def get_start_of_line_position(self, after_whitespace: bool = False) -> int: 

850 """Relative position for the start of this line.""" 

851 if after_whitespace: 

852 current_line = self.current_line 

853 return ( 

854 len(current_line) 

855 - len(current_line.lstrip()) 

856 - self.cursor_position_col 

857 ) 

858 else: 

859 return -len(self.current_line_before_cursor) 

860 

861 def get_end_of_line_position(self) -> int: 

862 """Relative position for the end of this line.""" 

863 return len(self.current_line_after_cursor) 

864 

865 def last_non_blank_of_current_line_position(self) -> int: 

866 """ 

867 Relative position for the last non blank character of this line. 

868 """ 

869 return len(self.current_line.rstrip()) - self.cursor_position_col - 1 

870 

871 def get_column_cursor_position(self, column: int) -> int: 

872 """ 

873 Return the relative cursor position for this column at the current 

874 line. (It will stay between the boundaries of the line in case of a 

875 larger number.) 

876 """ 

877 line_length = len(self.current_line) 

878 current_column = self.cursor_position_col 

879 column = max(0, min(line_length, column)) 

880 

881 return column - current_column 

882 

883 def selection_range( 

884 self, 

885 ) -> tuple[ 

886 int, int 

887 ]: # XXX: shouldn't this return `None` if there is no selection??? 

888 """ 

889 Return (from, to) tuple of the selection. 

890 start and end position are included. 

891 

892 This doesn't take the selection type into account. Use 

893 `selection_ranges` instead. 

894 """ 

895 if self.selection: 

896 from_, to = sorted( 

897 [self.cursor_position, self.selection.original_cursor_position] 

898 ) 

899 else: 

900 from_, to = self.cursor_position, self.cursor_position 

901 

902 return from_, to 

903 

904 def selection_ranges(self) -> Iterable[tuple[int, int]]: 

905 """ 

906 Return a list of `(from, to)` tuples for the selection or none if 

907 nothing was selected. The upper boundary is not included. 

908 

909 This will yield several (from, to) tuples in case of a BLOCK selection. 

910 This will return zero ranges, like (8,8) for empty lines in a block 

911 selection. 

912 """ 

913 if self.selection: 

914 from_, to = sorted( 

915 [self.cursor_position, self.selection.original_cursor_position] 

916 ) 

917 

918 if self.selection.type == SelectionType.BLOCK: 

919 from_line, from_column = self.translate_index_to_position(from_) 

920 to_line, to_column = self.translate_index_to_position(to) 

921 from_column, to_column = sorted([from_column, to_column]) 

922 lines = self.lines 

923 

924 if vi_mode(): 

925 to_column += 1 

926 

927 for l in range(from_line, to_line + 1): 

928 line_length = len(lines[l]) 

929 

930 if from_column <= line_length: 

931 yield ( 

932 self.translate_row_col_to_index(l, from_column), 

933 self.translate_row_col_to_index( 

934 l, min(line_length, to_column) 

935 ), 

936 ) 

937 else: 

938 # In case of a LINES selection, go to the start/end of the lines. 

939 if self.selection.type == SelectionType.LINES: 

940 from_ = max(0, self.text.rfind("\n", 0, from_) + 1) 

941 

942 if self.text.find("\n", to) >= 0: 

943 to = self.text.find("\n", to) 

944 else: 

945 to = len(self.text) - 1 

946 

947 # In Vi mode, the upper boundary is always included. For Emacs, 

948 # that's not the case. 

949 if vi_mode(): 

950 to += 1 

951 

952 yield from_, to 

953 

954 def selection_range_at_line(self, row: int) -> tuple[int, int] | None: 

955 """ 

956 If the selection spans a portion of the given line, return a (from, to) tuple. 

957 

958 The returned upper boundary is not included in the selection, so 

959 `(0, 0)` is an empty selection. `(0, 1)`, is a one character selection. 

960 

961 Returns None if the selection doesn't cover this line at all. 

962 """ 

963 if self.selection: 

964 line = self.lines[row] 

965 

966 row_start = self.translate_row_col_to_index(row, 0) 

967 row_end = self.translate_row_col_to_index(row, len(line)) 

968 

969 from_, to = sorted( 

970 [self.cursor_position, self.selection.original_cursor_position] 

971 ) 

972 

973 # Take the intersection of the current line and the selection. 

974 intersection_start = max(row_start, from_) 

975 intersection_end = min(row_end, to) 

976 

977 if intersection_start <= intersection_end: 

978 if self.selection.type == SelectionType.LINES: 

979 intersection_start = row_start 

980 intersection_end = row_end 

981 

982 elif self.selection.type == SelectionType.BLOCK: 

983 _, col1 = self.translate_index_to_position(from_) 

984 _, col2 = self.translate_index_to_position(to) 

985 col1, col2 = sorted([col1, col2]) 

986 

987 if col1 > len(line): 

988 return None # Block selection doesn't cross this line. 

989 

990 intersection_start = self.translate_row_col_to_index(row, col1) 

991 intersection_end = self.translate_row_col_to_index(row, col2) 

992 

993 _, from_column = self.translate_index_to_position(intersection_start) 

994 _, to_column = self.translate_index_to_position(intersection_end) 

995 

996 # In Vi mode, the upper boundary is always included. For Emacs 

997 # mode, that's not the case. 

998 if vi_mode(): 

999 to_column += 1 

1000 

1001 return from_column, to_column 

1002 return None 

1003 

1004 def cut_selection(self) -> tuple[Document, ClipboardData]: 

1005 """ 

1006 Return a (:class:`.Document`, :class:`.ClipboardData`) tuple, where the 

1007 document represents the new document when the selection is cut, and the 

1008 clipboard data, represents whatever has to be put on the clipboard. 

1009 """ 

1010 if self.selection: 

1011 cut_parts = [] 

1012 remaining_parts = [] 

1013 new_cursor_position = self.cursor_position 

1014 

1015 last_to = 0 

1016 for from_, to in self.selection_ranges(): 

1017 if last_to == 0: 

1018 new_cursor_position = from_ 

1019 

1020 remaining_parts.append(self.text[last_to:from_]) 

1021 cut_parts.append(self.text[from_:to]) 

1022 last_to = to 

1023 

1024 remaining_parts.append(self.text[last_to:]) 

1025 

1026 cut_text = "\n".join(cut_parts) 

1027 remaining_text = "".join(remaining_parts) 

1028 

1029 # In case of a LINES selection, don't include the trailing newline. 

1030 if self.selection.type == SelectionType.LINES and cut_text.endswith("\n"): 

1031 cut_text = cut_text[:-1] 

1032 

1033 return ( 

1034 Document(text=remaining_text, cursor_position=new_cursor_position), 

1035 ClipboardData(cut_text, self.selection.type), 

1036 ) 

1037 else: 

1038 return self, ClipboardData("") 

1039 

1040 def paste_clipboard_data( 

1041 self, 

1042 data: ClipboardData, 

1043 paste_mode: PasteMode = PasteMode.EMACS, 

1044 count: int = 1, 

1045 ) -> Document: 

1046 """ 

1047 Return a new :class:`.Document` instance which contains the result if 

1048 we would paste this data at the current cursor position. 

1049 

1050 :param paste_mode: Where to paste. (Before/after/emacs.) 

1051 :param count: When >1, Paste multiple times. 

1052 """ 

1053 before = paste_mode == PasteMode.VI_BEFORE 

1054 after = paste_mode == PasteMode.VI_AFTER 

1055 

1056 if data.type == SelectionType.CHARACTERS: 

1057 if after: 

1058 new_text = ( 

1059 self.text[: self.cursor_position + 1] 

1060 + data.text * count 

1061 + self.text[self.cursor_position + 1 :] 

1062 ) 

1063 else: 

1064 new_text = ( 

1065 self.text_before_cursor + data.text * count + self.text_after_cursor 

1066 ) 

1067 

1068 new_cursor_position = self.cursor_position + len(data.text) * count 

1069 if before: 

1070 new_cursor_position -= 1 

1071 

1072 elif data.type == SelectionType.LINES: 

1073 l = self.cursor_position_row 

1074 if before: 

1075 lines = self.lines[:l] + [data.text] * count + self.lines[l:] 

1076 new_text = "\n".join(lines) 

1077 new_cursor_position = len("".join(self.lines[:l])) + l 

1078 else: 

1079 lines = self.lines[: l + 1] + [data.text] * count + self.lines[l + 1 :] 

1080 new_cursor_position = len("".join(self.lines[: l + 1])) + l + 1 

1081 new_text = "\n".join(lines) 

1082 

1083 elif data.type == SelectionType.BLOCK: 

1084 lines = self.lines[:] 

1085 start_line = self.cursor_position_row 

1086 start_column = self.cursor_position_col + (0 if before else 1) 

1087 

1088 for i, line in enumerate(data.text.split("\n")): 

1089 index = i + start_line 

1090 if index >= len(lines): 

1091 lines.append("") 

1092 

1093 lines[index] = lines[index].ljust(start_column) 

1094 lines[index] = ( 

1095 lines[index][:start_column] 

1096 + line * count 

1097 + lines[index][start_column:] 

1098 ) 

1099 

1100 new_text = "\n".join(lines) 

1101 new_cursor_position = self.cursor_position + (0 if before else 1) 

1102 

1103 return Document(text=new_text, cursor_position=new_cursor_position) 

1104 

1105 def empty_line_count_at_the_end(self) -> int: 

1106 """ 

1107 Return number of empty lines at the end of the document. 

1108 """ 

1109 count = 0 

1110 for line in self.lines[::-1]: 

1111 if not line or line.isspace(): 

1112 count += 1 

1113 else: 

1114 break 

1115 

1116 return count 

1117 

1118 def start_of_paragraph(self, count: int = 1, before: bool = False) -> int: 

1119 """ 

1120 Return the start of the current paragraph. (Relative cursor position.) 

1121 """ 

1122 

1123 def match_func(text: str) -> bool: 

1124 return not text or text.isspace() 

1125 

1126 line_index = self.find_previous_matching_line( 

1127 match_func=match_func, count=count 

1128 ) 

1129 

1130 if line_index: 

1131 add = 0 if before else 1 

1132 return min(0, self.get_cursor_up_position(count=-line_index) + add) 

1133 else: 

1134 return -self.cursor_position 

1135 

1136 def end_of_paragraph(self, count: int = 1, after: bool = False) -> int: 

1137 """ 

1138 Return the end of the current paragraph. (Relative cursor position.) 

1139 """ 

1140 

1141 def match_func(text: str) -> bool: 

1142 return not text or text.isspace() 

1143 

1144 line_index = self.find_next_matching_line(match_func=match_func, count=count) 

1145 

1146 if line_index: 

1147 add = 0 if after else 1 

1148 return max(0, self.get_cursor_down_position(count=line_index) - add) 

1149 else: 

1150 return len(self.text_after_cursor) 

1151 

1152 # Modifiers. 

1153 

1154 def insert_after(self, text: str) -> Document: 

1155 """ 

1156 Create a new document, with this text inserted after the buffer. 

1157 It keeps selection ranges and cursor position in sync. 

1158 """ 

1159 return Document( 

1160 text=self.text + text, 

1161 cursor_position=self.cursor_position, 

1162 selection=self.selection, 

1163 ) 

1164 

1165 def insert_before(self, text: str) -> Document: 

1166 """ 

1167 Create a new document, with this text inserted before the buffer. 

1168 It keeps selection ranges and cursor position in sync. 

1169 """ 

1170 selection_state = self.selection 

1171 

1172 if selection_state: 

1173 selection_state = SelectionState( 

1174 original_cursor_position=selection_state.original_cursor_position 

1175 + len(text), 

1176 type=selection_state.type, 

1177 ) 

1178 

1179 return Document( 

1180 text=text + self.text, 

1181 cursor_position=self.cursor_position + len(text), 

1182 selection=selection_state, 

1183 )