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.2.2, created at 2023-03-26 06:07 +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 ( 

11 Callable, 

12 Dict, 

13 Iterable, 

14 List, 

15 NoReturn, 

16 Optional, 

17 Pattern, 

18 Tuple, 

19 cast, 

20) 

21 

22from .clipboard import ClipboardData 

23from .filters import vi_mode 

24from .selection import PasteMode, SelectionState, SelectionType 

25 

26__all__ = [ 

27 "Document", 

28] 

29 

30 

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

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

33# it doesn't contain a space.) 

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

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

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

37_FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile( 

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

39) 

40 

41# Regex for finding "WORDS" in documents. 

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

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

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

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

46 

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

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

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

50# `_DocumentCache`.) 

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

52 Dict[str, "_DocumentCache"], 

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

54) 

55 

56 

57class _ImmutableLineList(List[str]): 

58 """ 

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

60 (Useful for detecting obvious bugs.) 

61 """ 

62 

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

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

65 

66 __setitem__ = _error # type: ignore 

67 append = _error 

68 clear = _error 

69 extend = _error 

70 insert = _error 

71 pop = _error 

72 remove = _error 

73 reverse = _error 

74 sort = _error # type: ignore 

75 

76 

77class _DocumentCache: 

78 def __init__(self) -> None: 

79 #: List of lines for the Document text. 

80 self.lines: _ImmutableLineList | None = None 

81 

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

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

84 

85 

86class Document: 

87 """ 

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

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

90 

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

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

93 

94 :param text: string 

95 :param cursor_position: int 

96 :param selection: :class:`.SelectionState` 

97 """ 

98 

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

100 

101 def __init__( 

102 self, 

103 text: str = "", 

104 cursor_position: int | None = None, 

105 selection: SelectionState | None = None, 

106 ) -> None: 

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

108 # insert text.) 

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

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

111 ) 

112 

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

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

115 # sense in most places. 

116 if cursor_position is None: 

117 cursor_position = len(text) 

118 

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

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

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

122 self._text = text 

123 self._cursor_position = cursor_position 

124 self._selection = selection 

125 

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

127 # contain the same text. 

128 try: 

129 self._cache = _text_to_document_cache[self.text] 

130 except KeyError: 

131 self._cache = _DocumentCache() 

132 _text_to_document_cache[self.text] = self._cache 

133 

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

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

136 # 'setdefault' returns. 

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

138 # assert self._cache 

139 

140 def __repr__(self) -> str: 

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

142 

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

144 if not isinstance(other, Document): 

145 return False 

146 

147 return ( 

148 self.text == other.text 

149 and self.cursor_position == other.cursor_position 

150 and self.selection == other.selection 

151 ) 

152 

153 @property 

154 def text(self) -> str: 

155 "The document text." 

156 return self._text 

157 

158 @property 

159 def cursor_position(self) -> int: 

160 "The document cursor position." 

161 return self._cursor_position 

162 

163 @property 

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

165 ":class:`.SelectionState` object." 

166 return self._selection 

167 

168 @property 

169 def current_char(self) -> str: 

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

171 return self._get_char_relative_to_cursor(0) or "" 

172 

173 @property 

174 def char_before_cursor(self) -> str: 

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

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

177 

178 @property 

179 def text_before_cursor(self) -> str: 

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

181 

182 @property 

183 def text_after_cursor(self) -> str: 

184 return self.text[self.cursor_position :] 

185 

186 @property 

187 def current_line_before_cursor(self) -> str: 

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

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

190 return text 

191 

192 @property 

193 def current_line_after_cursor(self) -> str: 

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

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

196 return text 

197 

198 @property 

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

200 """ 

201 Array of all the lines. 

202 """ 

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

204 if self._cache.lines is None: 

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

206 

207 return self._cache.lines 

208 

209 @property 

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

211 """ 

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

213 """ 

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

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

216 if self._cache.line_indexes is None: 

217 # Create list of line lengths. 

218 line_lengths = map(len, self.lines) 

219 

220 # Calculate cumulative sums. 

221 indexes = [0] 

222 append = indexes.append 

223 pos = 0 

224 

225 for line_length in line_lengths: 

226 pos += line_length + 1 

227 append(pos) 

228 

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

230 if len(indexes) > 1: 

231 indexes.pop() 

232 

233 self._cache.line_indexes = indexes 

234 

235 return self._cache.line_indexes 

236 

237 @property 

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

239 """ 

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

241 """ 

242 return self.lines[self.cursor_position_row :] 

243 

244 @property 

245 def line_count(self) -> int: 

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

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

248 return len(self.lines) 

249 

250 @property 

251 def current_line(self) -> str: 

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

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

254 return self.current_line_before_cursor + self.current_line_after_cursor 

255 

256 @property 

257 def leading_whitespace_in_current_line(self) -> str: 

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

259 current_line = self.current_line 

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

261 return current_line[:length] 

262 

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

264 """ 

265 Return character relative to cursor position, or empty string 

266 """ 

267 try: 

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

269 except IndexError: 

270 return "" 

271 

272 @property 

273 def on_first_line(self) -> bool: 

274 """ 

275 True when we are at the first line. 

276 """ 

277 return self.cursor_position_row == 0 

278 

279 @property 

280 def on_last_line(self) -> bool: 

281 """ 

282 True when we are at the last line. 

283 """ 

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

285 

286 @property 

287 def cursor_position_row(self) -> int: 

288 """ 

289 Current row. (0-based.) 

290 """ 

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

292 return row 

293 

294 @property 

295 def cursor_position_col(self) -> int: 

296 """ 

297 Current column. (0-based.) 

298 """ 

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

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

301 # position.) 

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

303 return self.cursor_position - line_start_index 

304 

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

306 """ 

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

308 the first character on that line. 

309 

310 Return (row, index) tuple. 

311 """ 

312 indexes = self._line_start_indexes 

313 

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

315 return pos, indexes[pos] 

316 

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

318 """ 

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

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

321 """ 

322 # Find start of this line. 

323 row, row_index = self._find_line_start_index(index) 

324 col = index - row_index 

325 

326 return row, col 

327 

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

329 """ 

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

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

332 

333 Negative row/col values are turned into zero. 

334 """ 

335 try: 

336 result = self._line_start_indexes[row] 

337 line = self.lines[row] 

338 except IndexError: 

339 if row < 0: 

340 result = self._line_start_indexes[0] 

341 line = self.lines[0] 

342 else: 

343 result = self._line_start_indexes[-1] 

344 line = self.lines[-1] 

345 

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

347 

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

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

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

351 return result 

352 

353 @property 

354 def is_cursor_at_the_end(self) -> bool: 

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

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

357 

358 @property 

359 def is_cursor_at_the_end_of_line(self) -> bool: 

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

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

362 

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

364 """ 

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

366 """ 

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

368 

369 def find( 

370 self, 

371 sub: str, 

372 in_current_line: bool = False, 

373 include_current_position: bool = False, 

374 ignore_case: bool = False, 

375 count: int = 1, 

376 ) -> int | None: 

377 """ 

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

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

380 

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

382 """ 

383 assert isinstance(ignore_case, bool) 

384 

385 if in_current_line: 

386 text = self.current_line_after_cursor 

387 else: 

388 text = self.text_after_cursor 

389 

390 if not include_current_position: 

391 if len(text) == 0: 

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

393 else: 

394 text = text[1:] 

395 

396 flags = re.IGNORECASE if ignore_case else 0 

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

398 

399 try: 

400 for i, match in enumerate(iterator): 

401 if i + 1 == count: 

402 if include_current_position: 

403 return match.start(0) 

404 else: 

405 return match.start(0) + 1 

406 except StopIteration: 

407 pass 

408 return None 

409 

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

411 """ 

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

413 positions in the document. 

414 """ 

415 flags = re.IGNORECASE if ignore_case else 0 

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

417 

418 def find_backwards( 

419 self, 

420 sub: str, 

421 in_current_line: bool = False, 

422 ignore_case: bool = False, 

423 count: int = 1, 

424 ) -> int | None: 

425 """ 

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

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

428 

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

430 """ 

431 if in_current_line: 

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

433 else: 

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

435 

436 flags = re.IGNORECASE if ignore_case else 0 

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

438 

439 try: 

440 for i, match in enumerate(iterator): 

441 if i + 1 == count: 

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

443 except StopIteration: 

444 pass 

445 return None 

446 

447 def get_word_before_cursor( 

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

449 ) -> str: 

450 """ 

451 Give the word before the cursor. 

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

453 

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

455 pattern. 

456 """ 

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

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

459 return "" 

460 

461 text_before_cursor = self.text_before_cursor 

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

463 

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

465 

466 def _is_word_before_cursor_complete( 

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

468 ) -> bool: 

469 if pattern: 

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

471 else: 

472 return ( 

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

474 ) 

475 

476 def find_start_of_previous_word( 

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

478 ) -> int | None: 

479 """ 

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

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

482 

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

484 pattern. 

485 """ 

486 assert not (WORD and pattern) 

487 

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

489 # backwards search. 

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

491 

492 if pattern: 

493 regex = pattern 

494 elif WORD: 

495 regex = _FIND_BIG_WORD_RE 

496 else: 

497 regex = _FIND_WORD_RE 

498 

499 iterator = regex.finditer(text_before_cursor) 

500 

501 try: 

502 for i, match in enumerate(iterator): 

503 if i + 1 == count: 

504 return -match.end(0) 

505 except StopIteration: 

506 pass 

507 return None 

508 

509 def find_boundaries_of_current_word( 

510 self, 

511 WORD: bool = False, 

512 include_leading_whitespace: bool = False, 

513 include_trailing_whitespace: bool = False, 

514 ) -> tuple[int, int]: 

515 """ 

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

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

518 don't belong to any word.) 

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

520 """ 

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

522 text_after_cursor = self.current_line_after_cursor 

523 

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

525 return { 

526 (False, False): _FIND_CURRENT_WORD_RE, 

527 (False, True): _FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE, 

528 (True, False): _FIND_CURRENT_BIG_WORD_RE, 

529 (True, True): _FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE, 

530 }[(WORD, include_whitespace)] 

531 

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

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

534 

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

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

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

538 # before the cursor. 

539 if not WORD and match_before and match_after: 

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

541 c2 = self.text[self.cursor_position] 

542 alphabet = string.ascii_letters + "0123456789_" 

543 

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

545 match_before = None 

546 

547 return ( 

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

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

550 ) 

551 

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

553 """ 

554 Return the word, currently below the cursor. 

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

556 """ 

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

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

559 

560 def find_next_word_beginning( 

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

562 ) -> int | None: 

563 """ 

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

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

566 """ 

567 if count < 0: 

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

569 

570 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

571 iterator = regex.finditer(self.text_after_cursor) 

572 

573 try: 

574 for i, match in enumerate(iterator): 

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

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

577 count += 1 

578 

579 if i + 1 == count: 

580 return match.start(1) 

581 except StopIteration: 

582 pass 

583 return None 

584 

585 def find_next_word_ending( 

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

587 ) -> int | None: 

588 """ 

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

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

591 """ 

592 if count < 0: 

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

594 

595 if include_current_position: 

596 text = self.text_after_cursor 

597 else: 

598 text = self.text_after_cursor[1:] 

599 

600 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

601 iterable = regex.finditer(text) 

602 

603 try: 

604 for i, match in enumerate(iterable): 

605 if i + 1 == count: 

606 value = match.end(1) 

607 

608 if include_current_position: 

609 return value 

610 else: 

611 return value + 1 

612 

613 except StopIteration: 

614 pass 

615 return None 

616 

617 def find_previous_word_beginning( 

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

619 ) -> int | None: 

620 """ 

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

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

623 """ 

624 if count < 0: 

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

626 

627 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

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

629 

630 try: 

631 for i, match in enumerate(iterator): 

632 if i + 1 == count: 

633 return -match.end(1) 

634 except StopIteration: 

635 pass 

636 return None 

637 

638 def find_previous_word_ending( 

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

640 ) -> int | None: 

641 """ 

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

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

644 """ 

645 if count < 0: 

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

647 

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

649 

650 regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE 

651 iterator = regex.finditer(text_before_cursor) 

652 

653 try: 

654 for i, match in enumerate(iterator): 

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

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

657 count += 1 

658 

659 if i + 1 == count: 

660 return -match.start(1) + 1 

661 except StopIteration: 

662 pass 

663 return None 

664 

665 def find_next_matching_line( 

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

667 ) -> int | None: 

668 """ 

669 Look downwards for empty lines. 

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

671 """ 

672 result = None 

673 

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

675 if match_func(line): 

676 result = 1 + index 

677 count -= 1 

678 

679 if count == 0: 

680 break 

681 

682 return result 

683 

684 def find_previous_matching_line( 

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

686 ) -> int | None: 

687 """ 

688 Look upwards for empty lines. 

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

690 """ 

691 result = None 

692 

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

694 if match_func(line): 

695 result = -1 - index 

696 count -= 1 

697 

698 if count == 0: 

699 break 

700 

701 return result 

702 

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

704 """ 

705 Relative position for cursor left. 

706 """ 

707 if count < 0: 

708 return self.get_cursor_right_position(-count) 

709 

710 return -min(self.cursor_position_col, count) 

711 

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

713 """ 

714 Relative position for cursor_right. 

715 """ 

716 if count < 0: 

717 return self.get_cursor_left_position(-count) 

718 

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

720 

721 def get_cursor_up_position( 

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

723 ) -> int: 

724 """ 

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

726 user pressed the arrow-up button. 

727 

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

729 staying at the current column. 

730 """ 

731 assert count >= 1 

732 column = ( 

733 self.cursor_position_col if preferred_column is None else preferred_column 

734 ) 

735 

736 return ( 

737 self.translate_row_col_to_index( 

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

739 ) 

740 - self.cursor_position 

741 ) 

742 

743 def get_cursor_down_position( 

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

745 ) -> int: 

746 """ 

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

748 user pressed the arrow-down button. 

749 

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

751 staying at the current column. 

752 """ 

753 assert count >= 1 

754 column = ( 

755 self.cursor_position_col if preferred_column is None else preferred_column 

756 ) 

757 

758 return ( 

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

760 - self.cursor_position 

761 ) 

762 

763 def find_enclosing_bracket_right( 

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

765 ) -> int | None: 

766 """ 

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

768 position to the cursor position. 

769 

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

771 """ 

772 if self.current_char == right_ch: 

773 return 0 

774 

775 if end_pos is None: 

776 end_pos = len(self.text) 

777 else: 

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

779 

780 stack = 1 

781 

782 # Look forward. 

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

784 c = self.text[i] 

785 

786 if c == left_ch: 

787 stack += 1 

788 elif c == right_ch: 

789 stack -= 1 

790 

791 if stack == 0: 

792 return i - self.cursor_position 

793 

794 return None 

795 

796 def find_enclosing_bracket_left( 

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

798 ) -> int | None: 

799 """ 

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

801 position to the cursor position. 

802 

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

804 """ 

805 if self.current_char == left_ch: 

806 return 0 

807 

808 if start_pos is None: 

809 start_pos = 0 

810 else: 

811 start_pos = max(0, start_pos) 

812 

813 stack = 1 

814 

815 # Look backward. 

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

817 c = self.text[i] 

818 

819 if c == right_ch: 

820 stack += 1 

821 elif c == left_ch: 

822 stack -= 1 

823 

824 if stack == 0: 

825 return i - self.cursor_position 

826 

827 return None 

828 

829 def find_matching_bracket_position( 

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

831 ) -> int: 

832 """ 

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

834 

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

836 """ 

837 

838 # Look for a match. 

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

840 A = pair[0] 

841 B = pair[1] 

842 if self.current_char == A: 

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

844 elif self.current_char == B: 

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

846 

847 return 0 

848 

849 def get_start_of_document_position(self) -> int: 

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

851 return -self.cursor_position 

852 

853 def get_end_of_document_position(self) -> int: 

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

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

856 

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

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

859 if after_whitespace: 

860 current_line = self.current_line 

861 return ( 

862 len(current_line) 

863 - len(current_line.lstrip()) 

864 - self.cursor_position_col 

865 ) 

866 else: 

867 return -len(self.current_line_before_cursor) 

868 

869 def get_end_of_line_position(self) -> int: 

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

871 return len(self.current_line_after_cursor) 

872 

873 def last_non_blank_of_current_line_position(self) -> int: 

874 """ 

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

876 """ 

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

878 

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

880 """ 

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

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

883 larger number.) 

884 """ 

885 line_length = len(self.current_line) 

886 current_column = self.cursor_position_col 

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

888 

889 return column - current_column 

890 

891 def selection_range( 

892 self, 

893 ) -> tuple[ 

894 int, int 

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

896 """ 

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

898 start and end position are included. 

899 

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

901 `selection_ranges` instead. 

902 """ 

903 if self.selection: 

904 from_, to = sorted( 

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

906 ) 

907 else: 

908 from_, to = self.cursor_position, self.cursor_position 

909 

910 return from_, to 

911 

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

913 """ 

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

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

916 

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

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

919 selection. 

920 """ 

921 if self.selection: 

922 from_, to = sorted( 

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

924 ) 

925 

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

927 from_line, from_column = self.translate_index_to_position(from_) 

928 to_line, to_column = self.translate_index_to_position(to) 

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

930 lines = self.lines 

931 

932 if vi_mode(): 

933 to_column += 1 

934 

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

936 line_length = len(lines[l]) 

937 

938 if from_column <= line_length: 

939 yield ( 

940 self.translate_row_col_to_index(l, from_column), 

941 self.translate_row_col_to_index( 

942 l, min(line_length, to_column) 

943 ), 

944 ) 

945 else: 

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

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

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

949 

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

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

952 else: 

953 to = len(self.text) - 1 

954 

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

956 # that's not the case. 

957 if vi_mode(): 

958 to += 1 

959 

960 yield from_, to 

961 

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

963 """ 

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

965 

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

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

968 

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

970 """ 

971 if self.selection: 

972 line = self.lines[row] 

973 

974 row_start = self.translate_row_col_to_index(row, 0) 

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

976 

977 from_, to = sorted( 

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

979 ) 

980 

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

982 intersection_start = max(row_start, from_) 

983 intersection_end = min(row_end, to) 

984 

985 if intersection_start <= intersection_end: 

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

987 intersection_start = row_start 

988 intersection_end = row_end 

989 

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

991 _, col1 = self.translate_index_to_position(from_) 

992 _, col2 = self.translate_index_to_position(to) 

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

994 

995 if col1 > len(line): 

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

997 

998 intersection_start = self.translate_row_col_to_index(row, col1) 

999 intersection_end = self.translate_row_col_to_index(row, col2) 

1000 

1001 _, from_column = self.translate_index_to_position(intersection_start) 

1002 _, to_column = self.translate_index_to_position(intersection_end) 

1003 

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

1005 # mode, that's not the case. 

1006 if vi_mode(): 

1007 to_column += 1 

1008 

1009 return from_column, to_column 

1010 return None 

1011 

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

1013 """ 

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

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

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

1017 """ 

1018 if self.selection: 

1019 cut_parts = [] 

1020 remaining_parts = [] 

1021 new_cursor_position = self.cursor_position 

1022 

1023 last_to = 0 

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

1025 if last_to == 0: 

1026 new_cursor_position = from_ 

1027 

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

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

1030 last_to = to 

1031 

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

1033 

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

1035 remaining_text = "".join(remaining_parts) 

1036 

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

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

1039 cut_text = cut_text[:-1] 

1040 

1041 return ( 

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

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

1044 ) 

1045 else: 

1046 return self, ClipboardData("") 

1047 

1048 def paste_clipboard_data( 

1049 self, 

1050 data: ClipboardData, 

1051 paste_mode: PasteMode = PasteMode.EMACS, 

1052 count: int = 1, 

1053 ) -> Document: 

1054 """ 

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

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

1057 

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

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

1060 """ 

1061 before = paste_mode == PasteMode.VI_BEFORE 

1062 after = paste_mode == PasteMode.VI_AFTER 

1063 

1064 if data.type == SelectionType.CHARACTERS: 

1065 if after: 

1066 new_text = ( 

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

1068 + data.text * count 

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

1070 ) 

1071 else: 

1072 new_text = ( 

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

1074 ) 

1075 

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

1077 if before: 

1078 new_cursor_position -= 1 

1079 

1080 elif data.type == SelectionType.LINES: 

1081 l = self.cursor_position_row 

1082 if before: 

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

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

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

1086 else: 

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

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

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

1090 

1091 elif data.type == SelectionType.BLOCK: 

1092 lines = self.lines[:] 

1093 start_line = self.cursor_position_row 

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

1095 

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

1097 index = i + start_line 

1098 if index >= len(lines): 

1099 lines.append("") 

1100 

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

1102 lines[index] = ( 

1103 lines[index][:start_column] 

1104 + line * count 

1105 + lines[index][start_column:] 

1106 ) 

1107 

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

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

1110 

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

1112 

1113 def empty_line_count_at_the_end(self) -> int: 

1114 """ 

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

1116 """ 

1117 count = 0 

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

1119 if not line or line.isspace(): 

1120 count += 1 

1121 else: 

1122 break 

1123 

1124 return count 

1125 

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

1127 """ 

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

1129 """ 

1130 

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

1132 return not text or text.isspace() 

1133 

1134 line_index = self.find_previous_matching_line( 

1135 match_func=match_func, count=count 

1136 ) 

1137 

1138 if line_index: 

1139 add = 0 if before else 1 

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

1141 else: 

1142 return -self.cursor_position 

1143 

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

1145 """ 

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

1147 """ 

1148 

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

1150 return not text or text.isspace() 

1151 

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

1153 

1154 if line_index: 

1155 add = 0 if after else 1 

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

1157 else: 

1158 return len(self.text_after_cursor) 

1159 

1160 # Modifiers. 

1161 

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

1163 """ 

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

1165 It keeps selection ranges and cursor position in sync. 

1166 """ 

1167 return Document( 

1168 text=self.text + text, 

1169 cursor_position=self.cursor_position, 

1170 selection=self.selection, 

1171 ) 

1172 

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

1174 """ 

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

1176 It keeps selection ranges and cursor position in sync. 

1177 """ 

1178 selection_state = self.selection 

1179 

1180 if selection_state: 

1181 selection_state = SelectionState( 

1182 original_cursor_position=selection_state.original_cursor_position 

1183 + len(text), 

1184 type=selection_state.type, 

1185 ) 

1186 

1187 return Document( 

1188 text=text + self.text, 

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

1190 selection=selection_state, 

1191 )