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

530 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 typing import Callable, Dict, Iterable, List, NoReturn, Pattern, cast 

12 

13from .clipboard import ClipboardData 

14from .filters import vi_mode 

15from .selection import PasteMode, SelectionState, SelectionType 

16 

17__all__ = [ 

18 "Document", 

19] 

20 

21 

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

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

24# it doesn't contain a space.) 

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

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

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

28_FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile( 

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

30) 

31 

32# Regex for finding "WORDS" in documents. 

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

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

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

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

37 

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

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

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

41# `_DocumentCache`.) 

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

43 Dict[str, "_DocumentCache"], 

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

45) 

46 

47 

48class _ImmutableLineList(List[str]): 

49 """ 

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

51 (Useful for detecting obvious bugs.) 

52 """ 

53 

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

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

56 

57 __setitem__ = _error 

58 append = _error 

59 clear = _error 

60 extend = _error 

61 insert = _error 

62 pop = _error 

63 remove = _error 

64 reverse = _error 

65 sort = _error 

66 

67 

68class _DocumentCache: 

69 def __init__(self) -> None: 

70 #: List of lines for the Document text. 

71 self.lines: _ImmutableLineList | None = None 

72 

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

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

75 

76 

77class Document: 

78 """ 

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

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

81 

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

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

84 

85 :param text: string 

86 :param cursor_position: int 

87 :param selection: :class:`.SelectionState` 

88 """ 

89 

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

91 

92 def __init__( 

93 self, 

94 text: str = "", 

95 cursor_position: int | None = None, 

96 selection: SelectionState | None = None, 

97 ) -> None: 

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

99 # insert text.) 

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

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

102 ) 

103 

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

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

106 # sense in most places. 

107 if cursor_position is None: 

108 cursor_position = len(text) 

109 

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

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

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

113 self._text = text 

114 self._cursor_position = cursor_position 

115 self._selection = selection 

116 

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

118 # contain the same text. 

119 try: 

120 self._cache = _text_to_document_cache[self.text] 

121 except KeyError: 

122 self._cache = _DocumentCache() 

123 _text_to_document_cache[self.text] = self._cache 

124 

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

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

127 # 'setdefault' returns. 

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

129 # assert self._cache 

130 

131 def __repr__(self) -> str: 

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

133 

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

135 if not isinstance(other, Document): 

136 return False 

137 

138 return ( 

139 self.text == other.text 

140 and self.cursor_position == other.cursor_position 

141 and self.selection == other.selection 

142 ) 

143 

144 @property 

145 def text(self) -> str: 

146 "The document text." 

147 return self._text 

148 

149 @property 

150 def cursor_position(self) -> int: 

151 "The document cursor position." 

152 return self._cursor_position 

153 

154 @property 

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

156 ":class:`.SelectionState` object." 

157 return self._selection 

158 

159 @property 

160 def current_char(self) -> str: 

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

162 return self._get_char_relative_to_cursor(0) or "" 

163 

164 @property 

165 def char_before_cursor(self) -> str: 

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

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

168 

169 @property 

170 def text_before_cursor(self) -> str: 

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

172 

173 @property 

174 def text_after_cursor(self) -> str: 

175 return self.text[self.cursor_position :] 

176 

177 @property 

178 def current_line_before_cursor(self) -> str: 

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

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

181 return text 

182 

183 @property 

184 def current_line_after_cursor(self) -> str: 

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

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

187 return text 

188 

189 @property 

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

191 """ 

192 Array of all the lines. 

193 """ 

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

195 if self._cache.lines is None: 

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

197 

198 return self._cache.lines 

199 

200 @property 

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

202 """ 

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

204 """ 

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

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

207 if self._cache.line_indexes is None: 

208 # Create list of line lengths. 

209 line_lengths = map(len, self.lines) 

210 

211 # Calculate cumulative sums. 

212 indexes = [0] 

213 append = indexes.append 

214 pos = 0 

215 

216 for line_length in line_lengths: 

217 pos += line_length + 1 

218 append(pos) 

219 

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

221 if len(indexes) > 1: 

222 indexes.pop() 

223 

224 self._cache.line_indexes = indexes 

225 

226 return self._cache.line_indexes 

227 

228 @property 

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

230 """ 

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

232 """ 

233 return self.lines[self.cursor_position_row :] 

234 

235 @property 

236 def line_count(self) -> int: 

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

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

239 return len(self.lines) 

240 

241 @property 

242 def current_line(self) -> str: 

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

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

245 return self.current_line_before_cursor + self.current_line_after_cursor 

246 

247 @property 

248 def leading_whitespace_in_current_line(self) -> str: 

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

250 current_line = self.current_line 

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

252 return current_line[:length] 

253 

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

255 """ 

256 Return character relative to cursor position, or empty string 

257 """ 

258 try: 

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

260 except IndexError: 

261 return "" 

262 

263 @property 

264 def on_first_line(self) -> bool: 

265 """ 

266 True when we are at the first line. 

267 """ 

268 return self.cursor_position_row == 0 

269 

270 @property 

271 def on_last_line(self) -> bool: 

272 """ 

273 True when we are at the last line. 

274 """ 

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

276 

277 @property 

278 def cursor_position_row(self) -> int: 

279 """ 

280 Current row. (0-based.) 

281 """ 

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

283 return row 

284 

285 @property 

286 def cursor_position_col(self) -> int: 

287 """ 

288 Current column. (0-based.) 

289 """ 

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

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

292 # position.) 

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

294 return self.cursor_position - line_start_index 

295 

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

297 """ 

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

299 the first character on that line. 

300 

301 Return (row, index) tuple. 

302 """ 

303 indexes = self._line_start_indexes 

304 

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

306 return pos, indexes[pos] 

307 

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

309 """ 

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

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

312 """ 

313 # Find start of this line. 

314 row, row_index = self._find_line_start_index(index) 

315 col = index - row_index 

316 

317 return row, col 

318 

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

320 """ 

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

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

323 

324 Negative row/col values are turned into zero. 

325 """ 

326 try: 

327 result = self._line_start_indexes[row] 

328 line = self.lines[row] 

329 except IndexError: 

330 if row < 0: 

331 result = self._line_start_indexes[0] 

332 line = self.lines[0] 

333 else: 

334 result = self._line_start_indexes[-1] 

335 line = self.lines[-1] 

336 

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

338 

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

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

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

342 return result 

343 

344 @property 

345 def is_cursor_at_the_end(self) -> bool: 

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

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

348 

349 @property 

350 def is_cursor_at_the_end_of_line(self) -> bool: 

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

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

353 

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

355 """ 

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

357 """ 

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

359 

360 def find( 

361 self, 

362 sub: str, 

363 in_current_line: bool = False, 

364 include_current_position: bool = False, 

365 ignore_case: bool = False, 

366 count: int = 1, 

367 ) -> int | None: 

368 """ 

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

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

371 

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

373 """ 

374 assert isinstance(ignore_case, bool) 

375 

376 if in_current_line: 

377 text = self.current_line_after_cursor 

378 else: 

379 text = self.text_after_cursor 

380 

381 if not include_current_position: 

382 if len(text) == 0: 

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

384 else: 

385 text = text[1:] 

386 

387 flags = re.IGNORECASE if ignore_case else 0 

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

389 

390 try: 

391 for i, match in enumerate(iterator): 

392 if i + 1 == count: 

393 if include_current_position: 

394 return match.start(0) 

395 else: 

396 return match.start(0) + 1 

397 except StopIteration: 

398 pass 

399 return None 

400 

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

402 """ 

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

404 positions in the document. 

405 """ 

406 flags = re.IGNORECASE if ignore_case else 0 

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

408 

409 def find_backwards( 

410 self, 

411 sub: str, 

412 in_current_line: bool = False, 

413 ignore_case: bool = False, 

414 count: int = 1, 

415 ) -> int | None: 

416 """ 

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

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

419 

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

421 """ 

422 if in_current_line: 

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

424 else: 

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

426 

427 flags = re.IGNORECASE if ignore_case else 0 

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

429 

430 try: 

431 for i, match in enumerate(iterator): 

432 if i + 1 == count: 

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

434 except StopIteration: 

435 pass 

436 return None 

437 

438 def get_word_before_cursor( 

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

440 ) -> str: 

441 """ 

442 Give the word before the cursor. 

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

444 

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

446 pattern. 

447 """ 

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

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

450 return "" 

451 

452 text_before_cursor = self.text_before_cursor 

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

454 

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

456 

457 def _is_word_before_cursor_complete( 

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

459 ) -> bool: 

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

461 return True 

462 if pattern: 

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

464 

465 def find_start_of_previous_word( 

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

467 ) -> int | None: 

468 """ 

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

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

471 

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

473 pattern. 

474 """ 

475 assert not (WORD and pattern) 

476 

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

478 # backwards search. 

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

480 

481 if pattern: 

482 regex = pattern 

483 elif WORD: 

484 regex = _FIND_BIG_WORD_RE 

485 else: 

486 regex = _FIND_WORD_RE 

487 

488 iterator = regex.finditer(text_before_cursor) 

489 

490 try: 

491 for i, match in enumerate(iterator): 

492 if i + 1 == count: 

493 return -match.end(0) 

494 except StopIteration: 

495 pass 

496 return None 

497 

498 def find_boundaries_of_current_word( 

499 self, 

500 WORD: bool = False, 

501 include_leading_whitespace: bool = False, 

502 include_trailing_whitespace: bool = False, 

503 ) -> tuple[int, int]: 

504 """ 

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

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

507 don't belong to any word.) 

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

509 """ 

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

511 text_after_cursor = self.current_line_after_cursor 

512 

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

514 return { 

515 (False, False): _FIND_CURRENT_WORD_RE, 

516 (False, True): _FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE, 

517 (True, False): _FIND_CURRENT_BIG_WORD_RE, 

518 (True, True): _FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE, 

519 }[(WORD, include_whitespace)] 

520 

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

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

523 

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

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

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

527 # before the cursor. 

528 if not WORD and match_before and match_after: 

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

530 c2 = self.text[self.cursor_position] 

531 alphabet = string.ascii_letters + "0123456789_" 

532 

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

534 match_before = None 

535 

536 return ( 

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

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

539 ) 

540 

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

542 """ 

543 Return the word, currently below the cursor. 

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

545 """ 

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

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

548 

549 def find_next_word_beginning( 

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

551 ) -> int | None: 

552 """ 

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

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

555 """ 

556 if count < 0: 

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

558 

559 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

560 iterator = regex.finditer(self.text_after_cursor) 

561 

562 try: 

563 for i, match in enumerate(iterator): 

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

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

566 count += 1 

567 

568 if i + 1 == count: 

569 return match.start(1) 

570 except StopIteration: 

571 pass 

572 return None 

573 

574 def find_next_word_ending( 

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

576 ) -> int | None: 

577 """ 

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

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

580 """ 

581 if count < 0: 

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

583 

584 if include_current_position: 

585 text = self.text_after_cursor 

586 else: 

587 text = self.text_after_cursor[1:] 

588 

589 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

590 iterable = regex.finditer(text) 

591 

592 try: 

593 for i, match in enumerate(iterable): 

594 if i + 1 == count: 

595 value = match.end(1) 

596 

597 if include_current_position: 

598 return value 

599 else: 

600 return value + 1 

601 

602 except StopIteration: 

603 pass 

604 return None 

605 

606 def find_previous_word_beginning( 

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

608 ) -> int | None: 

609 """ 

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

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

612 """ 

613 if count < 0: 

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

615 

616 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

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

618 

619 try: 

620 for i, match in enumerate(iterator): 

621 if i + 1 == count: 

622 return -match.end(1) 

623 except StopIteration: 

624 pass 

625 return None 

626 

627 def find_previous_word_ending( 

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

629 ) -> int | None: 

630 """ 

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

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

633 """ 

634 if count < 0: 

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

636 

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

638 

639 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

640 iterator = regex.finditer(text_before_cursor) 

641 

642 try: 

643 for i, match in enumerate(iterator): 

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

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

646 count += 1 

647 

648 if i + 1 == count: 

649 return -match.start(1) + 1 

650 except StopIteration: 

651 pass 

652 return None 

653 

654 def find_next_matching_line( 

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

656 ) -> int | None: 

657 """ 

658 Look downwards for empty lines. 

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

660 """ 

661 result = None 

662 

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

664 if match_func(line): 

665 result = 1 + index 

666 count -= 1 

667 

668 if count == 0: 

669 break 

670 

671 return result 

672 

673 def find_previous_matching_line( 

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

675 ) -> int | None: 

676 """ 

677 Look upwards for empty lines. 

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

679 """ 

680 result = None 

681 

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

683 if match_func(line): 

684 result = -1 - index 

685 count -= 1 

686 

687 if count == 0: 

688 break 

689 

690 return result 

691 

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

693 """ 

694 Relative position for cursor left. 

695 """ 

696 if count < 0: 

697 return self.get_cursor_right_position(-count) 

698 

699 return -min(self.cursor_position_col, count) 

700 

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

702 """ 

703 Relative position for cursor_right. 

704 """ 

705 if count < 0: 

706 return self.get_cursor_left_position(-count) 

707 

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

709 

710 def get_cursor_up_position( 

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

712 ) -> int: 

713 """ 

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

715 user pressed the arrow-up button. 

716 

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

718 staying at the current column. 

719 """ 

720 assert count >= 1 

721 column = ( 

722 self.cursor_position_col if preferred_column is None else preferred_column 

723 ) 

724 

725 return ( 

726 self.translate_row_col_to_index( 

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

728 ) 

729 - self.cursor_position 

730 ) 

731 

732 def get_cursor_down_position( 

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

734 ) -> int: 

735 """ 

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

737 user pressed the arrow-down button. 

738 

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

740 staying at the current column. 

741 """ 

742 assert count >= 1 

743 column = ( 

744 self.cursor_position_col if preferred_column is None else preferred_column 

745 ) 

746 

747 return ( 

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

749 - self.cursor_position 

750 ) 

751 

752 def find_enclosing_bracket_right( 

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

754 ) -> int | None: 

755 """ 

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

757 position to the cursor position. 

758 

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

760 """ 

761 if self.current_char == right_ch: 

762 return 0 

763 

764 if end_pos is None: 

765 end_pos = len(self.text) 

766 else: 

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

768 

769 stack = 1 

770 

771 # Look forward. 

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

773 c = self.text[i] 

774 

775 if c == left_ch: 

776 stack += 1 

777 elif c == right_ch: 

778 stack -= 1 

779 

780 if stack == 0: 

781 return i - self.cursor_position 

782 

783 return None 

784 

785 def find_enclosing_bracket_left( 

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

787 ) -> int | None: 

788 """ 

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

790 position to the cursor position. 

791 

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

793 """ 

794 if self.current_char == left_ch: 

795 return 0 

796 

797 if start_pos is None: 

798 start_pos = 0 

799 else: 

800 start_pos = max(0, start_pos) 

801 

802 stack = 1 

803 

804 # Look backward. 

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

806 c = self.text[i] 

807 

808 if c == right_ch: 

809 stack += 1 

810 elif c == left_ch: 

811 stack -= 1 

812 

813 if stack == 0: 

814 return i - self.cursor_position 

815 

816 return None 

817 

818 def find_matching_bracket_position( 

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

820 ) -> int: 

821 """ 

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

823 

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

825 """ 

826 

827 # Look for a match. 

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

829 A = pair[0] 

830 B = pair[1] 

831 if self.current_char == A: 

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

833 elif self.current_char == B: 

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

835 

836 return 0 

837 

838 def get_start_of_document_position(self) -> int: 

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

840 return -self.cursor_position 

841 

842 def get_end_of_document_position(self) -> int: 

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

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

845 

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

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

848 if after_whitespace: 

849 current_line = self.current_line 

850 return ( 

851 len(current_line) 

852 - len(current_line.lstrip()) 

853 - self.cursor_position_col 

854 ) 

855 else: 

856 return -len(self.current_line_before_cursor) 

857 

858 def get_end_of_line_position(self) -> int: 

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

860 return len(self.current_line_after_cursor) 

861 

862 def last_non_blank_of_current_line_position(self) -> int: 

863 """ 

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

865 """ 

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

867 

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

869 """ 

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

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

872 larger number.) 

873 """ 

874 line_length = len(self.current_line) 

875 current_column = self.cursor_position_col 

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

877 

878 return column - current_column 

879 

880 def selection_range( 

881 self, 

882 ) -> tuple[ 

883 int, int 

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

885 """ 

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

887 start and end position are included. 

888 

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

890 `selection_ranges` instead. 

891 """ 

892 if self.selection: 

893 from_, to = sorted( 

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

895 ) 

896 else: 

897 from_, to = self.cursor_position, self.cursor_position 

898 

899 return from_, to 

900 

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

902 """ 

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

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

905 

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

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

908 selection. 

909 """ 

910 if self.selection: 

911 from_, to = sorted( 

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

913 ) 

914 

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

916 from_line, from_column = self.translate_index_to_position(from_) 

917 to_line, to_column = self.translate_index_to_position(to) 

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

919 lines = self.lines 

920 

921 if vi_mode(): 

922 to_column += 1 

923 

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

925 line_length = len(lines[l]) 

926 

927 if from_column <= line_length: 

928 yield ( 

929 self.translate_row_col_to_index(l, from_column), 

930 self.translate_row_col_to_index( 

931 l, min(line_length, to_column) 

932 ), 

933 ) 

934 else: 

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

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

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

938 

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

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

941 else: 

942 to = len(self.text) - 1 

943 

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

945 # that's not the case. 

946 if vi_mode(): 

947 to += 1 

948 

949 yield from_, to 

950 

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

952 """ 

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

954 

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

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

957 

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

959 """ 

960 if self.selection: 

961 line = self.lines[row] 

962 

963 row_start = self.translate_row_col_to_index(row, 0) 

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

965 

966 from_, to = sorted( 

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

968 ) 

969 

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

971 intersection_start = max(row_start, from_) 

972 intersection_end = min(row_end, to) 

973 

974 if intersection_start <= intersection_end: 

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

976 intersection_start = row_start 

977 intersection_end = row_end 

978 

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

980 _, col1 = self.translate_index_to_position(from_) 

981 _, col2 = self.translate_index_to_position(to) 

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

983 

984 if col1 > len(line): 

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

986 

987 intersection_start = self.translate_row_col_to_index(row, col1) 

988 intersection_end = self.translate_row_col_to_index(row, col2) 

989 

990 _, from_column = self.translate_index_to_position(intersection_start) 

991 _, to_column = self.translate_index_to_position(intersection_end) 

992 

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

994 # mode, that's not the case. 

995 if vi_mode(): 

996 to_column += 1 

997 

998 return from_column, to_column 

999 return None 

1000 

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

1002 """ 

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

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

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

1006 """ 

1007 if self.selection: 

1008 cut_parts = [] 

1009 remaining_parts = [] 

1010 new_cursor_position = self.cursor_position 

1011 

1012 last_to = 0 

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

1014 if last_to == 0: 

1015 new_cursor_position = from_ 

1016 

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

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

1019 last_to = to 

1020 

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

1022 

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

1024 remaining_text = "".join(remaining_parts) 

1025 

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

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

1028 cut_text = cut_text[:-1] 

1029 

1030 return ( 

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

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

1033 ) 

1034 else: 

1035 return self, ClipboardData("") 

1036 

1037 def paste_clipboard_data( 

1038 self, 

1039 data: ClipboardData, 

1040 paste_mode: PasteMode = PasteMode.EMACS, 

1041 count: int = 1, 

1042 ) -> Document: 

1043 """ 

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

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

1046 

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

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

1049 """ 

1050 before = paste_mode == PasteMode.VI_BEFORE 

1051 after = paste_mode == PasteMode.VI_AFTER 

1052 

1053 if data.type == SelectionType.CHARACTERS: 

1054 if after: 

1055 new_text = ( 

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

1057 + data.text * count 

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

1059 ) 

1060 else: 

1061 new_text = ( 

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

1063 ) 

1064 

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

1066 if before: 

1067 new_cursor_position -= 1 

1068 

1069 elif data.type == SelectionType.LINES: 

1070 l = self.cursor_position_row 

1071 if before: 

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

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

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

1075 else: 

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

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

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

1079 

1080 elif data.type == SelectionType.BLOCK: 

1081 lines = self.lines[:] 

1082 start_line = self.cursor_position_row 

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

1084 

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

1086 index = i + start_line 

1087 if index >= len(lines): 

1088 lines.append("") 

1089 

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

1091 lines[index] = ( 

1092 lines[index][:start_column] 

1093 + line * count 

1094 + lines[index][start_column:] 

1095 ) 

1096 

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

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

1099 

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

1101 

1102 def empty_line_count_at_the_end(self) -> int: 

1103 """ 

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

1105 """ 

1106 count = 0 

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

1108 if not line or line.isspace(): 

1109 count += 1 

1110 else: 

1111 break 

1112 

1113 return count 

1114 

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

1116 """ 

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

1118 """ 

1119 

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

1121 return not text or text.isspace() 

1122 

1123 line_index = self.find_previous_matching_line( 

1124 match_func=match_func, count=count 

1125 ) 

1126 

1127 if line_index: 

1128 add = 0 if before else 1 

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

1130 else: 

1131 return -self.cursor_position 

1132 

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

1134 """ 

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

1136 """ 

1137 

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

1139 return not text or text.isspace() 

1140 

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

1142 

1143 if line_index: 

1144 add = 0 if after else 1 

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

1146 else: 

1147 return len(self.text_after_cursor) 

1148 

1149 # Modifiers. 

1150 

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

1152 """ 

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

1154 It keeps selection ranges and cursor position in sync. 

1155 """ 

1156 return Document( 

1157 text=self.text + text, 

1158 cursor_position=self.cursor_position, 

1159 selection=self.selection, 

1160 ) 

1161 

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

1163 """ 

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

1165 It keeps selection ranges and cursor position in sync. 

1166 """ 

1167 selection_state = self.selection 

1168 

1169 if selection_state: 

1170 selection_state = SelectionState( 

1171 original_cursor_position=selection_state.original_cursor_position 

1172 + len(text), 

1173 type=selection_state.type, 

1174 ) 

1175 

1176 return Document( 

1177 text=text + self.text, 

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

1179 selection=selection_state, 

1180 )