Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/black/ranges.py: 14%

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

296 statements  

1"""Functions related to Black's formatting by line ranges feature.""" 

2 

3import difflib 

4from collections.abc import Collection, Iterator, Sequence 

5from dataclasses import dataclass 

6 

7from black.comments import FMT_SKIP, contains_fmt_directive 

8from black.nodes import ( 

9 LN, 

10 STANDALONE_COMMENT, 

11 Leaf, 

12 Node, 

13 Visitor, 

14 first_leaf, 

15 furthest_ancestor_with_last_leaf, 

16 last_leaf, 

17 syms, 

18) 

19from blib2to3.pgen2.token import ASYNC, NEWLINE 

20 

21 

22def parse_line_ranges(line_ranges: Sequence[str]) -> list[tuple[int, int]]: 

23 lines: list[tuple[int, int]] = [] 

24 for lines_str in line_ranges: 

25 parts = lines_str.split("-") 

26 if len(parts) != 2: 

27 raise ValueError( 

28 "Incorrect --line-ranges format, expect 'START-END', found" 

29 f" {lines_str!r}" 

30 ) 

31 try: 

32 start = int(parts[0]) 

33 end = int(parts[1]) 

34 except ValueError: 

35 raise ValueError( 

36 "Incorrect --line-ranges value, expect integer ranges, found" 

37 f" {lines_str!r}" 

38 ) from None 

39 else: 

40 if start < 1 or end < 1: 

41 raise ValueError( 

42 "Incorrect --line-ranges value, " 

43 f"expect positive integers, found {lines_str!r}" 

44 ) 

45 if start > end: 

46 raise ValueError( 

47 "Incorrect --line-ranges value, expect START <= END, " 

48 f"found {lines_str!r}" 

49 ) 

50 lines.append((start, end)) 

51 return lines 

52 

53 

54def is_valid_line_range(lines: tuple[int, int]) -> bool: 

55 """Returns whether the line range is valid.""" 

56 return lines[0] <= lines[1] 

57 

58 

59def sanitized_lines( 

60 lines: Collection[tuple[int, int]], src_contents: str 

61) -> Collection[tuple[int, int]]: 

62 """Returns the valid line ranges for the given source. 

63 

64 This removes ranges that are entirely outside the valid lines. 

65 

66 Other ranges are normalized so that the start values are at least 1 and the 

67 end values are at most the (1-based) index of the last source line. 

68 """ 

69 if not src_contents: 

70 return [] 

71 good_lines = [] 

72 src_line_count = src_contents.count("\n") 

73 if not src_contents.endswith("\n"): 

74 src_line_count += 1 

75 for start, end in lines: 

76 if start > src_line_count: 

77 continue 

78 # line-ranges are 1-based 

79 start = max(start, 1) 

80 if end < start: 

81 continue 

82 end = min(end, src_line_count) 

83 good_lines.append((start, end)) 

84 return good_lines 

85 

86 

87def adjusted_lines( 

88 lines: Collection[tuple[int, int]], 

89 original_source: str, 

90 modified_source: str, 

91) -> list[tuple[int, int]]: 

92 """Returns the adjusted line ranges based on edits from the original code. 

93 

94 This computes the new line ranges by diffing original_source and 

95 modified_source, and adjust each range based on how the range overlaps with 

96 the diffs. 

97 

98 Note the diff can contain lines outside of the original line ranges. This can 

99 happen when the formatting has to be done in adjacent to maintain consistent 

100 local results. For example: 

101 

102 1. def my_func(arg1, arg2, 

103 2. arg3,): 

104 3. pass 

105 

106 If it restricts to line 2-2, it can't simply reformat line 2, it also has 

107 to reformat line 1: 

108 

109 1. def my_func( 

110 2. arg1, 

111 3. arg2, 

112 4. arg3, 

113 5. ): 

114 6. pass 

115 

116 In this case, we will expand the line ranges to also include the whole diff 

117 block. 

118 

119 Args: 

120 lines: a collection of line ranges. 

121 original_source: the original source. 

122 modified_source: the modified source. 

123 """ 

124 if len(lines) > 1: 

125 original_lines = original_source.splitlines(keepends=True) 

126 modified_lines = modified_source.splitlines(keepends=True) 

127 if len(original_lines) == len(modified_lines) and all( 

128 1 <= start <= end <= len(original_lines) for start, end in lines 

129 ): 

130 selected_lines = { 

131 line for start, end in lines for line in range(start - 1, end) 

132 } 

133 # Equal-length edits confined to the requested ranges cannot shift 

134 # their line numbers. Diffing repeated text can move them anyway. 

135 if all( 

136 index in selected_lines or original == modified 

137 for index, (original, modified) in enumerate( 

138 zip(original_lines, modified_lines, strict=True) 

139 ) 

140 ): 

141 return sorted(lines) 

142 

143 lines_mappings = _calculate_lines_mappings(original_source, modified_source) 

144 

145 new_lines = [] 

146 # Keep an index of the current search. Since the lines and lines_mappings are 

147 # sorted, this makes the search complexity linear. 

148 current_mapping_index = 0 

149 for start, end in sorted(lines): 

150 start_mapping_index = _find_lines_mapping_index( 

151 start, 

152 lines_mappings, 

153 current_mapping_index, 

154 ) 

155 end_mapping_index = _find_lines_mapping_index( 

156 end, 

157 lines_mappings, 

158 start_mapping_index, 

159 ) 

160 current_mapping_index = start_mapping_index 

161 if start_mapping_index >= len(lines_mappings) or end_mapping_index >= len( 

162 lines_mappings 

163 ): 

164 # Protect against invalid inputs. 

165 continue 

166 start_mapping = lines_mappings[start_mapping_index] 

167 end_mapping = lines_mappings[end_mapping_index] 

168 if start_mapping.is_changed_block: 

169 # When the line falls into a changed block, expands to the whole block. 

170 new_start = start_mapping.modified_start 

171 else: 

172 new_start = ( 

173 start - start_mapping.original_start + start_mapping.modified_start 

174 ) 

175 if end_mapping.is_changed_block: 

176 # When the line falls into a changed block, expands to the whole block. 

177 new_end = end_mapping.modified_end 

178 else: 

179 new_end = end - end_mapping.original_start + end_mapping.modified_start 

180 new_range = (new_start, new_end) 

181 if is_valid_line_range(new_range): 

182 new_lines.append(new_range) 

183 return new_lines 

184 

185 

186def convert_unchanged_lines(src_node: Node, lines: Collection[tuple[int, int]]) -> None: 

187 r"""Converts unchanged lines to STANDALONE_COMMENT. 

188 

189 The idea is similar to how `# fmt: on/off` is implemented. It also converts the 

190 nodes between those markers as a single `STANDALONE_COMMENT` leaf node with 

191 the unformatted code as its value. `STANDALONE_COMMENT` is a "fake" token 

192 that will be formatted as-is with its prefix normalized. 

193 

194 Here we perform two passes: 

195 

196 1. Visit the top-level statements, and convert them to a single 

197 `STANDALONE_COMMENT` when unchanged. This speeds up formatting when some 

198 of the top-level statements aren't changed. 

199 2. Convert unchanged "unwrapped lines" to `STANDALONE_COMMENT` nodes line by 

200 line. "unwrapped lines" are divided by the `NEWLINE` token. e.g. a 

201 multi-line statement is *one* "unwrapped line" that ends with `NEWLINE`, 

202 even though this statement itself can span multiple lines, and the 

203 tokenizer only sees the last '\n' as the `NEWLINE` token. 

204 

205 NOTE: During pass (2), comment prefixes and indentations are ALWAYS 

206 normalized even when the lines aren't changed. This is fixable by moving 

207 more formatting to pass (1). However, it's hard to get it correct when 

208 incorrect indentations are used. So we defer this to future optimizations. 

209 """ 

210 lines_set = frozenset(n for start, end in lines for n in range(start, end + 1)) 

211 replacements = _NodeReplacements() 

212 visitor = _TopLevelStatementsVisitor(lines_set, replacements) 

213 _ = list(visitor.visit(src_node)) # Consume all results. 

214 replacements.apply() 

215 _convert_unchanged_line_by_line(src_node, lines_set) 

216 

217 

218class _NodeReplacements: 

219 """Collects STANDALONE_COMMENT conversions and applies them per parent. 

220 

221 Converting a node splices a `STANDALONE_COMMENT` leaf in place of a run of 

222 sibling nodes. Doing that one node at a time with `Base.remove` / 

223 `insert_child` scans and shifts the parent's children list on every call, so 

224 converting the many sibling blocks of one parent (a long if/elif chain, a 

225 match with many cases, a module with many top-level statements) is O(n^2). 

226 Recording the conversions and rewriting each parent's children in a single 

227 pass makes it O(n). 

228 """ 

229 

230 def __init__(self) -> None: 

231 # id(parent) -> (parent, {id(child): standalone to splice in}, {ids to drop}) 

232 # A run's nodes can live under more than one parent (an `async` statement 

233 # keeps the `ASYNC` leaf on the grandparent), so the standalone replaces 

234 # the run's first node under its own parent while the remaining nodes are 

235 # just dropped from wherever they sit. 

236 self._by_parent: dict[int, tuple[Node, dict[int, Leaf], set[int]]] = {} 

237 # NEWLINE leaves already inside a recorded run, so the line-by-line pass 

238 # doesn't record an overlapping conversion for them. 

239 self.covered_newlines: set[int] = set() 

240 # Nodes already scheduled for removal. When conversions immediately 

241 # mutated the tree, a second conversion touching an already-removed node 

242 # was a no-op (`Base.remove` returned None); recording defers the 

243 # mutation, so we skip such nodes explicitly to keep that behaviour. 

244 self._recorded: set[int] = set() 

245 # A leaf's prefix (indentation, blank lines, comments) moves onto its 

246 # STANDALONE_COMMENT. When an inner and an outer node that share the same 

247 # first leaf are both converted (a decorator inside a decorated block), 

248 # the earlier conversion clears the prefix off the leaf, so the later one 

249 # would see an empty prefix. Remember the taken prefix keyed by the leaf 

250 # so both conversions reuse the original. 

251 self._taken_prefixes: dict[int, str] = {} 

252 

253 def _entry(self, parent: Node) -> tuple[Node, dict[int, Leaf], set[int]]: 

254 return self._by_parent.setdefault(id(parent), (parent, {}, set())) 

255 

256 def take_prefix(self, first: Leaf) -> str: 

257 cached = self._taken_prefixes.get(id(first)) 

258 if cached is not None: 

259 return cached 

260 prefix = first.prefix 

261 first.prefix = "" 

262 # Immediate conversion replaced the whole run with a freshly built 

263 # STANDALONE_COMMENT leaf, whose synthesized position is line 0. Recording 

264 # the conversion leaves the original leaf in place, so mirror that line 0 

265 # here: when a later, enclosing node shares this first leaf (a decorated 

266 # block over an already-recorded decorator), _get_line_range sees the run 

267 # as starting at line 0, matching the immediate behaviour. 

268 first.lineno = 0 

269 self._taken_prefixes[id(first)] = prefix 

270 return prefix 

271 

272 def is_recorded(self, node: LN) -> bool: 

273 return id(node) in self._recorded 

274 

275 def record(self, run: list[LN], standalone: Leaf) -> None: 

276 first = run[0] 

277 first_parent = first.parent 

278 if first_parent is None or id(first) in self._recorded: 

279 return 

280 for node in run: 

281 self._recorded.add(id(node)) 

282 for leaf in node.leaves(): 

283 if leaf.type == NEWLINE: 

284 self.covered_newlines.add(id(leaf)) 

285 parent = node.parent 

286 if parent is not None: 

287 self._entry(parent)[2].add(id(node)) 

288 self._entry(first_parent)[1][id(first)] = standalone 

289 

290 def apply(self) -> None: 

291 for parent, replaced, removed in self._by_parent.values(): 

292 new_children: list[LN] = [] 

293 for child in parent.children: 

294 standalone = replaced.get(id(child)) 

295 if standalone is not None: 

296 standalone.parent = parent 

297 new_children.append(standalone) 

298 child.parent = None 

299 elif id(child) in removed: 

300 child.parent = None 

301 else: 

302 new_children.append(child) 

303 parent.children = new_children 

304 parent.invalidate_sibling_maps() 

305 

306 

307def _contains_standalone_comment(node: LN) -> bool: 

308 if isinstance(node, Leaf): 

309 return node.type == STANDALONE_COMMENT 

310 else: 

311 for child in node.children: 

312 if _contains_standalone_comment(child): 

313 return True 

314 return False 

315 

316 

317class _TopLevelStatementsVisitor(Visitor[None]): 

318 """ 

319 A node visitor that converts unchanged top-level statements to 

320 STANDALONE_COMMENT. 

321 

322 This is used in addition to _convert_unchanged_line_by_line, to 

323 speed up formatting when there are unchanged top-level 

324 classes/functions/statements. 

325 """ 

326 

327 def __init__(self, lines_set: frozenset[int], replacements: "_NodeReplacements"): 

328 self._lines_set = lines_set 

329 self._replacements = replacements 

330 

331 def visit_simple_stmt(self, node: Node) -> Iterator[None]: 

332 # This is only called for top-level statements, since `visit_suite` 

333 # won't visit its children nodes. 

334 yield from [] 

335 newline_leaf = last_leaf(node) 

336 if not newline_leaf: 

337 return 

338 assert ( 

339 newline_leaf.type == NEWLINE 

340 ), f"Unexpectedly found leaf.type={newline_leaf.type}" 

341 # We need to find the furthest ancestor with the NEWLINE as the last 

342 # leaf, since a `suite` can simply be a `simple_stmt` when it puts 

343 # its body on the same line. Example: `if cond: pass`. 

344 ancestor = furthest_ancestor_with_last_leaf(newline_leaf) 

345 if not _get_line_range(ancestor).intersection(self._lines_set): 

346 _convert_node_to_standalone_comment( 

347 ancestor, self._replacements, self._lines_set 

348 ) 

349 

350 def visit_suite(self, node: Node) -> Iterator[None]: 

351 yield from [] 

352 # If there is a STANDALONE_COMMENT node, it means parts of the node tree 

353 # have fmt on/off/skip markers. Those STANDALONE_COMMENT nodes can't 

354 # be simply converted by calling str(node). So we just don't convert 

355 # here. 

356 if _contains_standalone_comment(node): 

357 return 

358 # Find the semantic parent of this suite. For `async_stmt` and 

359 # `async_funcdef`, the ASYNC token is defined on a separate level by the 

360 # grammar. 

361 semantic_parent = node.parent 

362 if semantic_parent is not None: 

363 if ( 

364 semantic_parent.prev_sibling is not None 

365 and semantic_parent.prev_sibling.type == ASYNC 

366 ): 

367 semantic_parent = semantic_parent.parent 

368 if semantic_parent is not None and not _get_line_range( 

369 semantic_parent 

370 ).intersection(self._lines_set): 

371 _convert_node_to_standalone_comment( 

372 semantic_parent, self._replacements, self._lines_set 

373 ) 

374 

375 

376def _convert_unchanged_line_by_line(node: Node, lines_set: frozenset[int]) -> None: 

377 """Converts unchanged to STANDALONE_COMMENT line by line.""" 

378 replacements = _NodeReplacements() 

379 for leaf in node.leaves(): 

380 if leaf.type != NEWLINE: 

381 # We only consider "unwrapped lines", which are divided by the NEWLINE 

382 # token. 

383 continue 

384 if id(leaf) in replacements.covered_newlines: 

385 # This NEWLINE is inside a run already scheduled for conversion (e.g. 

386 # a second decorator on a stacked decorator block). 

387 continue 

388 if leaf.parent and leaf.parent.type == syms.match_stmt: 

389 # The `suite` node is defined as: 

390 # match_stmt: "match" subject_expr ':' NEWLINE INDENT case_block+ DEDENT 

391 # Here we need to check `subject_expr`. The `case_block+` will be 

392 # checked by their own NEWLINEs. 

393 nodes_to_ignore: list[LN] = [] 

394 prev_sibling = leaf.prev_sibling 

395 while prev_sibling: 

396 nodes_to_ignore.insert(0, prev_sibling) 

397 prev_sibling = prev_sibling.prev_sibling 

398 if not _get_line_range(nodes_to_ignore).intersection(lines_set): 

399 _convert_nodes_to_standalone_comment( 

400 nodes_to_ignore, 

401 newline=leaf, 

402 replacements=replacements, 

403 lines_set=lines_set, 

404 ) 

405 elif leaf.parent and leaf.parent.type == syms.suite: 

406 # The `suite` node is defined as: 

407 # suite: simple_stmt | NEWLINE INDENT stmt+ DEDENT 

408 # We will check `simple_stmt` and `stmt+` separately against the lines set 

409 parent_sibling = leaf.parent.prev_sibling 

410 nodes_to_ignore = [] 

411 while parent_sibling and parent_sibling.type != syms.suite: 

412 # NOTE: Multiple suite nodes can exist as siblings in e.g. `if_stmt`. 

413 nodes_to_ignore.insert(0, parent_sibling) 

414 parent_sibling = parent_sibling.prev_sibling 

415 # Special case for `async_stmt` and `async_funcdef` where the ASYNC 

416 # token is on the grandparent node. 

417 grandparent = leaf.parent.parent 

418 if ( 

419 grandparent is not None 

420 and grandparent.prev_sibling is not None 

421 and grandparent.prev_sibling.type == ASYNC 

422 ): 

423 nodes_to_ignore.insert(0, grandparent.prev_sibling) 

424 if not _get_line_range(nodes_to_ignore).intersection(lines_set): 

425 _convert_nodes_to_standalone_comment( 

426 nodes_to_ignore, 

427 newline=leaf, 

428 replacements=replacements, 

429 lines_set=lines_set, 

430 ) 

431 else: 

432 ancestor = furthest_ancestor_with_last_leaf(leaf) 

433 # Consider multiple decorators as a whole block, as their 

434 # newlines have different behaviors than the rest of the grammar. 

435 if ( 

436 ancestor.type == syms.decorator 

437 and ancestor.parent 

438 and ancestor.parent.type == syms.decorators 

439 ): 

440 ancestor = ancestor.parent 

441 if not _get_line_range(ancestor).intersection(lines_set): 

442 _convert_node_to_standalone_comment(ancestor, replacements, lines_set) 

443 replacements.apply() 

444 

445 

446def _str_with_standalone_comments(*nodes: LN) -> str: 

447 """Like `"".join(str(node) for node in nodes)`, but a nested STANDALONE_COMMENT 

448 that ends in a comment is followed by a newline. 

449 

450 A STANDALONE_COMMENT made by `# fmt: skip` handling, or by a `# fmt: off` / 

451 `# fmt: on` block that only holds comments, can end with the comment and has no 

452 trailing newline: the line generator puts one after it. Joining it straight to 

453 the next leaf would append that leaf (e.g. a closing bracket) to the comment's 

454 line and turn it into part of the comment. 

455 """ 

456 leaves = [leaf for node in nodes for leaf in node.leaves()] 

457 parts = [] 

458 for leaf, next_leaf in zip(leaves, [*leaves[1:], None], strict=True): 

459 text = str(leaf) 

460 if ( 

461 leaf.type == STANDALONE_COMMENT 

462 and next_leaf is not None 

463 and ( 

464 # A comment-only fmt:off/on block ends with the `# fmt: on` comment. 

465 # A newline in the next leaf's prefix is a blank line after it. 

466 leaf.fmt_pass_converted_first_leaf is None 

467 or (not str(next_leaf).startswith("\n") and _ends_with_fmt_skip(text)) 

468 ) 

469 ): 

470 text += "\n" 

471 parts.append(text) 

472 return "".join(parts) 

473 

474 

475def _ends_with_fmt_skip(text: str) -> bool: 

476 """Whether the last line of a STANDALONE_COMMENT value ends in a `# fmt: skip`. 

477 

478 Only that directive makes such a value end in a comment without a trailing 

479 newline. Any other `#` is ignored: it may be inside a string. 

480 """ 

481 last_line = text.rpartition("\n")[2] 

482 comment_start = last_line.find("#") 

483 return comment_start != -1 and contains_fmt_directive( 

484 last_line[comment_start:], FMT_SKIP 

485 ) 

486 

487 

488def _convert_node_to_standalone_comment( 

489 node: LN, replacements: "_NodeReplacements", lines_set: frozenset[int] 

490) -> None: 

491 """Convert node to STANDALONE_COMMENT by modifying the tree inline.""" 

492 parent = node.parent 

493 if not parent or replacements.is_recorded(node): 

494 return 

495 first = first_leaf(node) 

496 last = last_leaf(node) 

497 if not first or not last: 

498 return 

499 if first is last: 

500 # This can happen on the following edge cases: 

501 # 1. A block of `# fmt: off/on` code except the `# fmt: on` is placed 

502 # on the end of the last line instead of on a new line. 

503 # 2. A single backslash on its own line followed by a comment line. 

504 # Ideally we don't want to format them when not requested, but fixing 

505 # isn't easy. These cases are also badly formatted code, so it isn't 

506 # too bad we reformat them. 

507 return 

508 # The prefix contains comments and indentation whitespaces. They are 

509 # reformatted accordingly to the correct indentation level. 

510 # This also means the indentation will be changed on the unchanged lines, and 

511 # this is actually required to not break incremental reformatting. 

512 first_lineno = first.lineno 

513 prefix = replacements.take_prefix(first) 

514 # For a single-line decorated item the decorator and the item need a newline 

515 # between them. The conversions are recorded and applied in a single pass 

516 # instead of mutating the tree per node, so the decorator here is still its 

517 # own child node carrying its trailing NEWLINE, which already separates it 

518 # from the item; there's nothing to add back. (A decorated item spanning 

519 # multiple lines is handled by the earlier suite case in 

520 # _convert_unchanged_line_by_line, which manages the newlines itself.) 

521 # Remove the '\n', as STANDALONE_COMMENT will have '\n' appended when 

522 # generating the formatted code. 

523 value = _str_with_standalone_comments(node)[:-1] 

524 replacements.record( 

525 [node], 

526 Leaf( 

527 STANDALONE_COMMENT, 

528 value, 

529 prefix=prefix, 

530 fmt_pass_converted_first_leaf=first, 

531 line_ranges_first_lineno=first_lineno, 

532 line_ranges_selected=lines_set, 

533 ), 

534 ) 

535 

536 

537def _convert_nodes_to_standalone_comment( 

538 nodes: Sequence[LN], 

539 *, 

540 newline: Leaf, 

541 replacements: "_NodeReplacements", 

542 lines_set: frozenset[int], 

543) -> None: 

544 """Convert nodes to STANDALONE_COMMENT by modifying the tree inline.""" 

545 if not nodes: 

546 return 

547 parent = nodes[0].parent 

548 first = first_leaf(nodes[0]) 

549 if not parent or not first or replacements.is_recorded(nodes[0]): 

550 return 

551 first_lineno = first.lineno 

552 prefix = replacements.take_prefix(first) 

553 value = _str_with_standalone_comments(*nodes) 

554 # The prefix comment on the NEWLINE leaf is the trailing comment of the statement. 

555 if newline.prefix: 

556 value += newline.prefix 

557 newline.prefix = "" 

558 replacements.record( 

559 list(nodes), 

560 Leaf( 

561 STANDALONE_COMMENT, 

562 value, 

563 prefix=prefix, 

564 fmt_pass_converted_first_leaf=first, 

565 line_ranges_first_lineno=first_lineno, 

566 line_ranges_selected=lines_set, 

567 ), 

568 ) 

569 

570 

571def _leaf_line_end(leaf: Leaf) -> int: 

572 """Returns the line number of the leaf node's last line.""" 

573 if leaf.type == NEWLINE: 

574 return leaf.lineno 

575 else: 

576 # Leaf nodes like multiline strings can occupy multiple lines. 

577 return leaf.lineno + str(leaf).count("\n") 

578 

579 

580def _get_line_range(node_or_nodes: LN | list[LN]) -> set[int]: 

581 """Returns the line range of this node or list of nodes.""" 

582 if isinstance(node_or_nodes, list): 

583 nodes = node_or_nodes 

584 if not nodes: 

585 return set() 

586 first = first_leaf(nodes[0]) 

587 last = last_leaf(nodes[-1]) 

588 if first and last: 

589 line_start = first.lineno 

590 line_end = _leaf_line_end(last) 

591 return set(range(line_start, line_end + 1)) 

592 else: 

593 return set() 

594 else: 

595 node = node_or_nodes 

596 if isinstance(node, Leaf): 

597 return set(range(node.lineno, _leaf_line_end(node) + 1)) 

598 else: 

599 first = first_leaf(node) 

600 last = last_leaf(node) 

601 if first and last: 

602 return set(range(first.lineno, _leaf_line_end(last) + 1)) 

603 else: 

604 return set() 

605 

606 

607@dataclass 

608class _LinesMapping: 

609 """1-based lines mapping from original source to modified source. 

610 

611 Lines [original_start, original_end] from original source 

612 are mapped to [modified_start, modified_end]. 

613 

614 The ranges are inclusive on both ends. 

615 """ 

616 

617 original_start: int 

618 original_end: int 

619 modified_start: int 

620 modified_end: int 

621 # Whether this range corresponds to a changed block, or an unchanged block. 

622 is_changed_block: bool 

623 

624 

625def _calculate_lines_mappings( 

626 original_source: str, 

627 modified_source: str, 

628) -> Sequence[_LinesMapping]: 

629 """Returns a sequence of _LinesMapping by diffing the sources. 

630 

631 For example, given the following diff: 

632 import re 

633 - def func(arg1, 

634 - arg2, arg3): 

635 + def func(arg1, arg2, arg3): 

636 pass 

637 It returns the following mappings: 

638 original -> modified 

639 (1, 1) -> (1, 1), is_changed_block=False (the "import re" line) 

640 (2, 3) -> (2, 2), is_changed_block=True (the diff) 

641 (4, 4) -> (3, 3), is_changed_block=False (the "pass" line) 

642 

643 You can think of this visually as if it brings up a side-by-side diff, and tries 

644 to map the line ranges from the left side to the right side: 

645 

646 (1, 1)->(1, 1) 1. import re 1. import re 

647 (2, 3)->(2, 2) 2. def func(arg1, 2. def func(arg1, arg2, arg3): 

648 3. arg2, arg3): 

649 (4, 4)->(3, 3) 4. pass 3. pass 

650 

651 Args: 

652 original_source: the original source. 

653 modified_source: the modified source. 

654 """ 

655 original_lines = original_source.splitlines(keepends=True) 

656 modified_lines = modified_source.splitlines(keepends=True) 

657 

658 # SequenceMatcher can align repeated unchanged lines at a different position, 

659 # making a change in the middle look like an insertion at the start and a 

660 # deletion at the end. Anchor the common edges before diffing the middle. 

661 prefix = 0 

662 while ( 

663 prefix < min(len(original_lines), len(modified_lines)) 

664 and original_lines[prefix] == modified_lines[prefix] 

665 ): 

666 prefix += 1 

667 

668 suffix = 0 

669 while ( 

670 suffix < min(len(original_lines), len(modified_lines)) - prefix 

671 and original_lines[-suffix - 1] == modified_lines[-suffix - 1] 

672 ): 

673 suffix += 1 

674 

675 original_middle_end = len(original_lines) - suffix 

676 modified_middle_end = len(modified_lines) - suffix 

677 # The first formatting pass re-indents the bodies of the statements it 

678 # formats, so the same line can have a different indentation in the two 

679 # sources. Comparing the lines without indentation keeps them matched. 

680 matcher = difflib.SequenceMatcher( 

681 None, 

682 [line.strip() for line in original_lines[prefix:original_middle_end]], 

683 [line.strip() for line in modified_lines[prefix:modified_middle_end]], 

684 ) 

685 matching_blocks = [] 

686 if prefix: 

687 matching_blocks.append(difflib.Match(0, 0, prefix)) 

688 matching_blocks.extend( 

689 difflib.Match(block.a + prefix, block.b + prefix, block.size) 

690 for block in matcher.get_matching_blocks() 

691 if block.size 

692 ) 

693 if suffix: 

694 matching_blocks.append( 

695 difflib.Match(original_middle_end, modified_middle_end, suffix) 

696 ) 

697 matching_blocks.append(difflib.Match(len(original_lines), len(modified_lines), 0)) 

698 lines_mappings: list[_LinesMapping] = [] 

699 # matching_blocks is a sequence of "same block of code ranges", see 

700 # https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.get_matching_blocks 

701 # Each block corresponds to a _LinesMapping with is_changed_block=False, 

702 # and the ranges between two blocks corresponds to a _LinesMapping with 

703 # is_changed_block=True, 

704 # NOTE: matching_blocks is 0-based, but _LinesMapping is 1-based. 

705 for i, block in enumerate(matching_blocks): 

706 if i == 0: 

707 if block.a != 0 or block.b != 0: 

708 lines_mappings.append( 

709 _LinesMapping( 

710 original_start=1, 

711 original_end=block.a, 

712 modified_start=1, 

713 modified_end=block.b, 

714 # The lines before the first matching block are not part of 

715 # any matching block, so this range is a changed block. A line 

716 # that only exists on one side has no counterpart to map onto. 

717 is_changed_block=True, 

718 ) 

719 ) 

720 else: 

721 previous_block = matching_blocks[i - 1] 

722 lines_mappings.append( 

723 _LinesMapping( 

724 original_start=previous_block.a + previous_block.size + 1, 

725 original_end=block.a, 

726 modified_start=previous_block.b + previous_block.size + 1, 

727 modified_end=block.b, 

728 is_changed_block=True, 

729 ) 

730 ) 

731 if i < len(matching_blocks) - 1: 

732 lines_mappings.append( 

733 _LinesMapping( 

734 original_start=block.a + 1, 

735 original_end=block.a + block.size, 

736 modified_start=block.b + 1, 

737 modified_end=block.b + block.size, 

738 is_changed_block=False, 

739 ) 

740 ) 

741 return lines_mappings 

742 

743 

744def _find_lines_mapping_index( 

745 original_line: int, 

746 lines_mappings: Sequence[_LinesMapping], 

747 start_index: int, 

748) -> int: 

749 """Returns the original index of the lines mappings for the original line.""" 

750 index = start_index 

751 while index < len(lines_mappings): 

752 mapping = lines_mappings[index] 

753 if mapping.original_start <= original_line <= mapping.original_end: 

754 return index 

755 index += 1 

756 return index