Coverage for src/renaissance/syntax_tree/ast_rewriter.py: 87%

319 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-15 10:34 +0000

1# Copyright (C) 2024-2026 TNO 

2# 

3# Licensed under the terms specified in the LICENSE file in the root directory 

4# of this repository, also available at: 

5# https://github.com/TNO/Renaissance.Py/blob/main/LICENSE 

6 

7"""AI: Rewriter that translates AST-level replace/remove/insert actions into byte-level edits.""" 

8 

9import re 

10import sys 

11from collections.abc import Sequence 

12from enum import Enum 

13from typing import Protocol, Self, runtime_checkable 

14 

15from more_itertools import flatten 

16 

17from renaissance.common import Rewriter 

18from renaissance.utils.text_utils import TextUtils 

19 

20from .match_finder import PatternMatch 

21from .semantic_kind import SemanticKind 

22 

23 

24@runtime_checkable 

25class Rewritable(Protocol): 

26 """AI: Structural protocol describing the offset/text shape required for byte-level rewriting.""" 

27 

28 # Read-only members keep the protocol covariant, so implementations may expose them as properties. 

29 @property 

30 def offset(self) -> int: 

31 """Character offset at which this node starts.""" 

32 ... 

33 

34 @property 

35 def end_offset(self) -> int: 

36 """Character offset just past this node's own text.""" 

37 ... 

38 

39 @property 

40 def extended_end_offset(self) -> int: 

41 """Character offset just past this node including trailing trivia.""" 

42 ... 

43 

44 @property 

45 def filename(self) -> str: 

46 """Name of the source file this node was parsed from.""" 

47 ... 

48 

49 @property 

50 def parent(self) -> Self | None: 

51 """Enclosing node, or None at the root.""" 

52 ... 

53 

54 @property 

55 def text(self) -> str: 

56 """Source text of this node.""" 

57 ... 

58 

59 

60class _RewriteActionType(Enum): 

61 REPLACE = 1 

62 INSERT_BEFORE = 2 

63 INSERT_AFTER = 3 

64 REMOVE = 4 # TODO: Why needed? Why isn't a REMOVE Action Type just a REPLACE Action Type (with an empty string)? 

65 

66 

67DEFAULT_INDENT = 4 

68 

69 

70class ASTRewriter: 

71 """AI: Rewriter that translates AST-level replace/remove/insert actions into byte-level edits.""" 

72 

73 def __init__( 

74 self, 

75 node, 

76 # TODO: see docs/developer/architecture/position-consistency.md - the encoding must come from the adapter, not the host. 

77 encoding: str = sys.getfilesystemencoding(), 

78 correct_indent: bool = True, 

79 ) -> None: 

80 """AI: Accumulate and apply text rewrites (replace/insert/remove) to an AST node's source.""" 

81 self.__rewrites = _RewriteActions(node, encoding, correct_indent=correct_indent) 

82 self.__filename = node.filename 

83 

84 def get_filename(self) -> str: 

85 """AI: Return the filename of the AST node this rewriter operates on.""" 

86 return self.__filename 

87 

88 def replace( 

89 self, 

90 new_content: str, 

91 target: Rewritable | Sequence[Rewritable] | PatternMatch | Sequence[PatternMatch], 

92 include_whitespace: bool = True, 

93 include_comments: bool = True, 

94 ) -> None: 

95 """AI: Queue a rewrite replacing target's source text with new_content.""" 

96 self.__rewrites.add( 

97 _RewriteActionType.REPLACE, 

98 target, 

99 new_content, 

100 include_whitespace, 

101 include_comments, 

102 ) 

103 

104 def remove( 

105 self, 

106 target: Rewritable | Sequence[Rewritable] | PatternMatch | Sequence[PatternMatch], 

107 include_whitespace: bool = True, 

108 include_comments: bool = True, 

109 ) -> None: 

110 """AI: Queue a rewrite removing target's source text.""" 

111 self.__rewrites.add(_RewriteActionType.REMOVE, target, "", include_whitespace, include_comments) 

112 

113 def insert_before( 

114 self, 

115 new_content: str, 

116 target: Rewritable | Sequence[Rewritable] | PatternMatch | Sequence[PatternMatch], 

117 include_whitespace: bool = True, 

118 include_comments: bool = True, 

119 ) -> None: 

120 """AI: Queue a rewrite inserting new_content immediately before target's source text.""" 

121 self.__rewrites.add( 

122 _RewriteActionType.INSERT_BEFORE, 

123 target, 

124 new_content, 

125 include_whitespace, 

126 include_comments, 

127 ) 

128 

129 def insert_after( 

130 self, 

131 new_content: str, 

132 target: Rewritable | Sequence[Rewritable] | PatternMatch | Sequence[PatternMatch], 

133 include_whitespace: bool = True, 

134 include_comments: bool = True, 

135 ) -> None: 

136 """AI: Queue a rewrite inserting new_content immediately after target's source text.""" 

137 self.__rewrites.add( 

138 _RewriteActionType.INSERT_AFTER, 

139 target, 

140 new_content, 

141 include_whitespace, 

142 include_comments, 

143 ) 

144 

145 def apply_to_string(self) -> str: 

146 """AI: Return the rewritten source as a string, applying all queued rewrites.""" 

147 return self.__rewrites.apply_to_string() 

148 

149 def apply(self) -> bytes: 

150 """AI: Return the rewritten source as bytes, applying all queued rewrites (or the original content if none are queued).""" 

151 if len(self.__rewrites.rewrites) == 0: 

152 return self.__rewrites.content 

153 return self.__rewrites.apply() 

154 

155 def has_changed(self) -> bool: 

156 """AI: Return whether any rewrites have been queued.""" 

157 return len(self.__rewrites.rewrites) > 0 

158 

159 @staticmethod 

160 def _get_comment_location(start_offset: int, stop_offset: int, content: bytes) -> tuple[int, int]: 

161 return _RewriteActions.get_comment_location(start_offset, stop_offset, content) 

162 

163 

164class _RewriteAction: 

165 """Data container for a rewrite action to be applied later on to the AST.""" 

166 

167 def __init__( 

168 self, 

169 action: _RewriteActionType, 

170 target: Rewritable | Sequence[Rewritable] | PatternMatch | Sequence[PatternMatch], 

171 replacement: str, 

172 include_whitespace: bool, 

173 include_comments: bool, 

174 ) -> None: 

175 self.action = action 

176 self.target = target 

177 self.replacement = replacement 

178 self.nodes = self._get_nodes(target) 

179 self.include_whitespace = include_whitespace 

180 self.include_comments = include_comments 

181 

182 @staticmethod 

183 def _get_nodes( 

184 target: Rewritable | Sequence[Rewritable] | PatternMatch | Sequence[PatternMatch], 

185 ) -> Sequence[Rewritable]: 

186 if isinstance(target, Rewritable): 

187 return [target] 

188 if isinstance(target, PatternMatch): 

189 return target.nodes 

190 assert isinstance(target, Sequence), "type of target violates its type requirements " + type(target).__name__ 

191 if len(target) > 0: 

192 if isinstance(target[0], Rewritable): 

193 return [n for n in target if isinstance(n, Rewritable)] 

194 last = target[-1] 

195 assert isinstance(last, PatternMatch), "type within Sequence violates its requirements " + type(last).__name__ 

196 return last.nodes 

197 # TODO: is this correct? Can the other matches indeed be ignored? 

198 return [] 

199 

200 

201class _RewriteActions: 

202 """Data container for a list of rewrite actions to be applied later on to the AST.""" 

203 

204 def __init__( 

205 self, 

206 node: Rewritable, 

207 encoding: str, 

208 correct_indent: bool, 

209 rewrites: list[_RewriteAction] | None = None, 

210 ) -> None: 

211 self.rewrites: list[_RewriteAction] = rewrites or [] 

212 self.node = node 

213 self.encoding = encoding 

214 # self.content = self.node.root.binary_file_content()[self.node.offset : self.node.extended_end_offset] 

215 # TODO: see docs/developer/architecture/position-consistency.md - re-encodes node text instead of using the root buffer. 

216 self.content = node.text.encode(sys.getfilesystemencoding()) 

217 self.correct_indent = correct_indent 

218 

219 def add( 

220 self, 

221 action: _RewriteActionType, 

222 target: Rewritable | Sequence[Rewritable] | PatternMatch | Sequence[PatternMatch], 

223 replacement: str, 

224 include_whitespace: bool, 

225 include_comments: bool, 

226 ) -> None: 

227 rewrite = _RewriteAction(action, target, replacement, include_whitespace, include_comments) 

228 self.add_rewrite(rewrite) 

229 

230 def add_rewrite(self, rewrite: _RewriteAction) -> None: 

231 self.rewrites.append(rewrite) 

232 

233 def apply(self) -> bytes: 

234 self.__check_for_conflicting_rewrites() 

235 rewriter = Rewriter(self.content[:]) 

236 

237 for rewrite in self.rewrites: 

238 # skip nested rewrites as they are handled recursively by the parent rewrite 

239 # except for if the rewrite node is the root node 

240 if any(self.__is_ancestor_in_nodes(n) for n in rewrite.nodes if n != self.node): 

241 continue 

242 new_content, nodelist = self.__prepare_replacement_content(rewrite.replacement, rewrite.target) 

243 if rewrite.action == _RewriteActionType.REPLACE: 

244 self.__replace( 

245 rewriter, 

246 new_content, 

247 nodelist, 

248 rewrite.include_whitespace, 

249 rewrite.include_comments, 

250 ) 

251 elif rewrite.action == _RewriteActionType.INSERT_BEFORE: 

252 self.__insert( 

253 rewriter, 

254 new_content, 

255 True, 

256 nodelist, 

257 rewrite.include_whitespace, 

258 rewrite.include_comments, 

259 ) 

260 elif rewrite.action == _RewriteActionType.INSERT_AFTER: 

261 self.__insert( 

262 rewriter, 

263 new_content, 

264 False, 

265 nodelist, 

266 rewrite.include_whitespace, 

267 rewrite.include_comments, 

268 ) 

269 elif rewrite.action == _RewriteActionType.REMOVE: 

270 self.__remove( 

271 rewriter, 

272 nodelist, 

273 rewrite.include_whitespace, 

274 rewrite.include_comments, 

275 ) 

276 return rewriter.apply() 

277 

278 def apply_to_string(self) -> str: 

279 return self.apply().decode(self.encoding) 

280 

281 def __check_for_conflicting_rewrites(self) -> None: 

282 """Raise if two queued replace/remove rewrites target overlapping source ranges. 

283 

284 Insert rewrites are ignored: they only add text before or after a node without 

285 overwriting it, so they can be combined with any other rewrite. A node nested inside 

286 another rewritten node is not treated as overlapping. 

287 

288 TODO: this only turns silent corruption into a clear error - it doesn't merge 

289 conflicting rewrites into a correct result. Recipes must still avoid queuing more than 

290 one rewrite per node/range before a commit. 

291 """ 

292 # INSERT_BEFORE/AFTER can work with each other and with REPLACE/REMOVE 

293 replacing = [r for r in self.rewrites if r.action in (_RewriteActionType.REPLACE, _RewriteActionType.REMOVE)] 

294 all_nodes = [(rewrite, node) for rewrite in replacing for node in rewrite.nodes if node != self.node] 

295 for i, (rewrite_a, node_a) in enumerate(all_nodes): 

296 for rewrite_b, node_b in all_nodes[i + 1 :]: 

297 if rewrite_a is rewrite_b or self.__is_nested(node_a, node_b) or self.__is_nested(node_b, node_a): 

298 continue 

299 if not (node_a.end_offset < node_b.offset or node_a.offset > node_b.end_offset): 

300 raise ValueError( 

301 f"Conflicting rewrites queued for overlapping source ranges " 

302 f"({node_a.offset}-{node_a.end_offset} and {node_b.offset}-{node_b.end_offset}) " 

303 "in the same file - applying both would corrupt the output." 

304 ) 

305 

306 @staticmethod 

307 def __is_nested(node: Rewritable, maybe_ancestor: Rewritable) -> bool: 

308 """Return True if maybe_ancestor is an ancestor of node, walking .parent. 

309 

310 Not node.is_ancestor_of() - not every Rewritable implements it (e.g. PythonRstNode). 

311 """ 

312 parent = node.parent 

313 while parent: 

314 if parent is maybe_ancestor: 

315 return True 

316 parent = parent.parent 

317 return False 

318 

319 def __is_ancestor_in_nodes(self, node: Rewritable) -> bool: 

320 """Check if the given node is a descendant of any nodes in the rewrite list. 

321 

322 Args: 

323 node (Rewritable): The node to check. 

324 

325 Returns: 

326 bool: True if the node is a descendant of any nodes in the rewrite list, False otherwise. 

327 

328 """ 

329 rewrite_nodes = list(flatten(rewrite.nodes for rewrite in self.rewrites)) 

330 

331 # need to test 

332 # 1 

333 # | node | 

334 # |rew| 

335 # 2 

336 # | rew | 

337 # |node| 

338 def no_conflict(node1, rew) -> bool: 

339 return not (node1.end_offset < rew.offset or node1.offset > rew.end_offset) 

340 

341 result = any(no_conflict(node, rew) for rew in rewrite_nodes) 

342 

343 return result and False # TODO: Why `and False` 

344 

345 def __replace( 

346 self, 

347 rewriter: Rewriter, 

348 new_content: str, 

349 nodes: Sequence[Rewritable], 

350 include_whitespace: bool, 

351 include_comments: bool, 

352 ) -> None: 

353 """Replace the content of the given node(s) with new content. 

354 

355 Args: 

356 rewriter (Rewriter): The rewriter used to apply the content replacement. 

357 new_content (str): The new content to insert in the specified range. 

358 nodes (Sequence[Rewritable]): The nodes whose content is to be replaced. 

359 include_whitespace (bool): Whether to include surrounding whitespace when determining the replacement range. 

360 include_comments (bool): Whether to include surrounding comments when determining the replacement range. 

361 

362 """ 

363 if not nodes: 

364 return 

365 start_offset, end_offset = _RewriteActions.__correct_for_comments_and_whitespace( 

366 self.node.offset, 

367 self.content, 

368 include_whitespace, 

369 include_comments, 

370 nodes, 

371 ) 

372 # start_offset =nodes[0].get_start_offset() 

373 # end_offset =nodes[-1].get_start_offset()+nodes[-1].get_length()+1 

374 indent = self.derive_indent(start_offset) 

375 if self.correct_indent: 

376 if new_content.startswith("\n"): 

377 # a blank first line would otherwise strand the original indent as trailing whitespace 

378 start_offset -= indent 

379 new_content = TextUtils.shift_right(new_content, indent, start_line=1) 

380 self.__replace_bytes(rewriter, start_offset, end_offset, new_content) 

381 

382 def __remove( 

383 self, 

384 rewriter: Rewriter, 

385 nodes: Sequence[Rewritable], 

386 include_whitespace: bool = False, 

387 include_comments: bool = False, 

388 ) -> None: 

389 """Remove a list of AST nodes from the content, optionally including surrounding whitespace and comments. 

390 

391 Args: 

392 rewriter (Rewriter): The rewriter used to apply the removal. 

393 nodes (Sequence[Rewritable]): The list of AST nodes to remove. 

394 include_whitespace (bool, optional): Whether to include surrounding whitespace in the removal. Defaults to False. 

395 include_comments (bool, optional): Whether to include surrounding comments in the removal. Defaults to False. 

396 

397 """ 

398 if not nodes: 

399 return 

400 

401 start_offset, end_offset = _RewriteActions.__correct_for_comments_and_whitespace( 

402 self.node.offset, 

403 self.content, 

404 include_whitespace, 

405 include_comments, 

406 nodes, 

407 ) 

408 indent = self.derive_indent(start_offset) 

409 # remove the indent in front of it 

410 start_offset -= indent 

411 # remove the line if it is empty 

412 if ( 

413 start_offset > 0 

414 and self.content[start_offset - 1] == ord("\n") 

415 and (end_offset >= len(self.content) or self.content[end_offset] == ord("\n")) 

416 ): 

417 start_offset -= 1 

418 self.__replace_bytes(rewriter, start_offset, end_offset, "") 

419 

420 def derive_indent(self, start_offset: int) -> int: 

421 indent = 0 # len(nodes[0].indent) 

422 if start_offset > 0: 

423 while len(self.content) > (start_offset - indent - 1) and self.content[start_offset - indent - 1] == 32: 

424 indent += 1 

425 return indent 

426 

427 def __insert( 

428 self, 

429 rewriter: Rewriter, 

430 new_content: str, 

431 before: bool, 

432 nodes: Sequence[Rewritable], 

433 include_whitespace: bool, 

434 include_comments: bool, 

435 ) -> None: 

436 if not nodes: 

437 return 

438 content = self.content 

439 indent = TextUtils.get_spaces_before(content, nodes[0].offset) 

440 spaces = " " * indent 

441 # if flattened_nodes[-1] has a new line after white space then we need to add a new line: 

442 ext_start_offset, ext_end_offset = _RewriteActions.__correct_for_comments_and_whitespace( 

443 self.node.offset, 

444 self.content, 

445 include_whitespace, 

446 include_comments, 

447 nodes, 

448 ) 

449 white_space = ( 

450 "" 

451 if not include_whitespace 

452 else "\n" + spaces 

453 if ext_end_offset < len(content) and content[ext_end_offset] in b"\n" 

454 else spaces 

455 ) 

456 # indent the new content except the first line 

457 new_content = TextUtils.shift_right(new_content, indent, start_line=1) 

458 

459 if before: 

460 # restore the node's original indent, consumed as new_content's first-line indent 

461 if not white_space and new_content.endswith("\n"): 

462 new_content += spaces 

463 self.__replace_bytes(rewriter, ext_start_offset, ext_start_offset, new_content + white_space) 

464 else: 

465 self.__replace_bytes(rewriter, ext_end_offset, ext_end_offset, white_space + new_content) 

466 

467 def __replace_bytes(self, rewriter: Rewriter, start: int, end: int, new_content: str) -> None: 

468 """Replace the content in the specified range with new content. 

469 

470 Args: 

471 rewriter (Rewriter): The rewriter used to apply the byte replacement. 

472 start (int): The starting index of the range to be replaced. 

473 end (int): The ending index of the range to be replaced. 

474 new_content (str): The new content to insert in the specified range. 

475 

476 """ 

477 rewriter.replace(start, end, new_content.encode(self.encoding)) 

478 

479 def __compose_replacement(self, replacement: str, matches: Sequence[PatternMatch]) -> str: 

480 all_placeholders = {p: n for m in matches for p, n in m.expansions.items()} 

481 for placeholder, nodes in all_placeholders.items(): 

482 quoted_placeholder = re.escape(placeholder) 

483 raw_signature = self.__get_texts(nodes) 

484 # replacement = replacement.replace(placeholder, raw_signature) 

485 while placeholder in replacement: 

486 pattern = re.compile(r"( *)" + quoted_placeholder) 

487 matcher = pattern.search(replacement) 

488 

489 if matcher: 

490 spaces = matcher[1] 

491 place_holder_length = len(placeholder) 

492 index = replacement.index(placeholder) 

493 # TODO a regex may be provided between backticks and the groups are used. This needs a better design 

494 # A preferable solution is to pass a transformer function to the compose_replacement 

495 if index + place_holder_length < len(replacement) and replacement[index + place_holder_length] == "`": 

496 # ` ` means get regex 

497 end_index = replacement.index("`", index + place_holder_length + 1) 

498 if not end_index: 

499 raise ValueError("No closing ` found") 

500 regex = replacement[index + place_holder_length + 1 : end_index] 

501 regex_match = re.match(regex, raw_signature) 

502 if regex_match: 

503 raw_signature = "".join(regex_match.groups()) 

504 place_holder_length = end_index - index + 1 

505 indent_replacement = raw_signature.replace("\n", "\n" + spaces) 

506 if ( 

507 placeholder.startswith("$$") 

508 and index + place_holder_length < len(replacement) 

509 and replacement[index + place_holder_length] == ";" 

510 ): 

511 place_holder_length += 1 

512 # replace the placeholder with the indent replacement 

513 replacement = replacement[:index] + indent_replacement + replacement[index + place_holder_length :] 

514 else: 

515 print("Match doesn't match unexpectedly") 

516 return replacement 

517 

518 def __get_texts(self, nodes: Sequence[Rewritable]) -> str: 

519 if len(nodes) == 1: 

520 return self.__get_text(nodes[0]) 

521 # Use a ASTRewriter to only rewrite exactly that what needs to be rewritten 

522 rewriter = ASTRewriter(nodes[0], self.encoding, correct_indent=False) 

523 for node in nodes: 

524 rs = self.__get_text(node) 

525 org_rs = node.text 

526 if rs != org_rs: 

527 rewriter.replace(rs, node) 

528 result = rewriter.apply_to_string() 

529 indent = self.derive_indent(nodes[0].offset) 

530 return TextUtils.shift_left(result, indent, start_line=1) 

531 

532 def __get_text(self, node: Rewritable) -> str: 

533 if self._should_skip(node): 

534 return "" 

535 

536 if node == self.node: 

537 return node.text 

538 # the descendants may need to be rewritten as well 

539 # rewrites = [rewrite for rewrite in self.rewrites if any(node.is_ancestor_of(rewrite_node) 

540 # for rewrite_node in rewrite.nodes)] 

541 rewrites = [rewrite for rewrite in self.rewrites if any(node.is_ancestor_of(rewrite_node) for rewrite_node in rewrite.nodes)] 

542 if rewrites: 

543 rewriter = _RewriteActions(node, self.encoding, self.correct_indent, rewrites) 

544 return rewriter.apply_to_string() 

545 return node.text 

546 

547 def __prepare_replacement_content( 

548 self, 

549 new_content: str, 

550 target: PatternMatch | Rewritable | Sequence[Rewritable], 

551 ) -> tuple[str, Sequence[Rewritable]]: 

552 if isinstance(target, PatternMatch): 

553 new_content = self.__compose_replacement(new_content, [target]) 

554 node_list = target.nodes 

555 else: 

556 node_list = ( 

557 [target] if isinstance(target, Rewritable) else target 

558 ) # TODO How to make a Sequence[Rewritable] as type hints also show list[Rewritable]? 

559 return new_content, node_list 

560 

561 def _should_skip(self, node: Rewritable): 

562 """If the node is not the first node of a pattern match it should be skipped.""" 

563 return any(node in rewrite.nodes[1:] for rewrite in self.rewrites if isinstance(rewrite.target, PatternMatch)) 

564 

565 @staticmethod 

566 def _get_parent_statement(node: Rewritable) -> Rewritable | None: 

567 parent = node 

568 while parent and not parent.is_statement: 

569 parent = parent.parent 

570 return parent 

571 

572 @staticmethod 

573 def __correct_for_comments_and_whitespace( 

574 offset: int, 

575 content: bytes, 

576 include_whitespace: bool, 

577 include_comments: bool, 

578 nodes: Sequence[Rewritable], 

579 ) -> tuple[int, int]: 

580 start_offset = nodes[0].offset - offset 

581 end_offset = nodes[-1].extended_end_offset - offset 

582 if include_comments: 

583 preceding_node = nodes[0].preceding_sibling 

584 parent = nodes[0].parent 

585 start_comment_location = 0 

586 if preceding_node: 

587 # start after the comment of the preceding node 

588 start_comment_location = preceding_node.extended_end_offset - offset 

589 preceding_end_offset = _RewriteActions.__get_comment_after_location(start_comment_location, start_offset, content) 

590 if preceding_end_offset != (-1, -1): 

591 start_comment_location = preceding_end_offset[1] 

592 elif parent: 

593 start_comment_location = parent.offset - offset 

594 # get the comment belonging to the preceding node 

595 extended_location = _RewriteActions.get_comment_location(start_comment_location, start_offset, content) 

596 if extended_location != (-1, -1): 

597 start_offset = extended_location[0] 

598 next_sibling = nodes[-1].next_sibling 

599 end_comment_location = next_sibling.offset - offset if next_sibling else parent.end_offset - offset if parent else len(content) 

600 location_after_comment = _RewriteActions.__get_comment_after_location(end_offset, end_comment_location, content) 

601 if location_after_comment != (-1, -1): 

602 end_offset = location_after_comment[1] 

603 if include_whitespace: 

604 end_offset = _RewriteActions.__extend_with_whitespace(end_offset, content) 

605 return start_offset, end_offset 

606 

607 def cor_offset(self, offset: int): 

608 return offset - self.node.offset 

609 

610 @staticmethod 

611 def get_comment_location(start_offset: int, stop_offset: int, content: bytes) -> tuple[int, int]: 

612 """Get the location of the comment before the location, but after the stop_location. 

613 

614 A comment is a line that starts with // or a block that starts with /* and ends with */ 

615 or a line that starts with #. 

616 """ 

617 # TODO: the // and # branches below only find the single closest comment line 

618 # search last occurrence of //, /*, # in a byte array 

619 comment_start = content.rfind(b"//", start_offset, stop_offset) 

620 if comment_start != -1: 

621 comment_end = _RewriteActions.__get_end_of_line(content, comment_start) 

622 return comment_start, comment_end 

623 comment_start = content.rfind(b"/*", start_offset, stop_offset) 

624 if comment_start != -1: 

625 comment_end = content.find(b"*/", comment_start, stop_offset) 

626 if comment_end != -1: 

627 comment_end += len("*/") 

628 return comment_start, comment_end 

629 comment_start = content.rfind(b"#", start_offset, stop_offset) 

630 if comment_start != -1: 

631 comment_end = _RewriteActions.__get_end_of_line(content, comment_start) 

632 return comment_start, comment_end 

633 return -1, -1 

634 

635 @staticmethod 

636 def __extend_with_whitespace(start_offset: int, content: bytes) -> int: 

637 end_location = _RewriteActions.__get_end_of_line(content, start_offset) 

638 text = content[start_offset:end_location] 

639 for byt in text: 

640 if byt not in b" \t": 

641 return start_offset 

642 return end_location 

643 

644 @staticmethod 

645 def __get_comment_after_location(start_offset: int, end_offset: int, content: bytes) -> tuple[int, int]: 

646 """Get the location of the comment before the location, but after the stop_location. 

647 

648 A comment is a line that starts with // or a block that starts with /* and ends with */ 

649 or a line that starts with #. 

650 """ 

651 # TODO: same single-line limitation as get_comment_location - a multi-line trailing 

652 # comment block is only captured up to its first line here. 

653 line_end_offset = _RewriteActions.__get_end_of_line(content, start_offset) 

654 if line_end_offset == -1: 

655 line_end_offset = len(content) 

656 comment_start = content.find(b"//", start_offset, line_end_offset) 

657 if comment_start == -1: 

658 comment_start = content.rfind(b"#", start_offset, line_end_offset) 

659 if comment_start != -1: 

660 return comment_start, line_end_offset 

661 comment_start = content.rfind(b"/*", start_offset, line_end_offset) 

662 if comment_start != -1: 

663 # a block comment must start on the same line but doesn't have to finish on the same line 

664 comment_end = content.find(b"*/", comment_start, end_offset) 

665 if comment_end != -1: 

666 comment_end += len("*/") 

667 return comment_start, comment_end 

668 return -1, -1 

669 

670 @staticmethod 

671 def __get_end_of_line(content: bytes, start: int) -> int: 

672 location = content.find(b"\n", start) 

673 if location == -1: 

674 return len(content) 

675 return location 

676 

677 @staticmethod 

678 def __get_depth(node: Rewritable) -> int: 

679 depth = 0 

680 parent = node.parent 

681 while parent: 

682 if parent.semantic_kind is SemanticKind.STATEMENT or parent.parser_kind in {"CompoundStmt", "COMPOUND_STMT"}: 

683 depth += 1 

684 parent = parent.parent 

685 return depth