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
« 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
7"""AI: Rewriter that translates AST-level replace/remove/insert actions into byte-level edits."""
9import re
10import sys
11from collections.abc import Sequence
12from enum import Enum
13from typing import Protocol, Self, runtime_checkable
15from more_itertools import flatten
17from renaissance.common import Rewriter
18from renaissance.utils.text_utils import TextUtils
20from .match_finder import PatternMatch
21from .semantic_kind import SemanticKind
24@runtime_checkable
25class Rewritable(Protocol):
26 """AI: Structural protocol describing the offset/text shape required for byte-level rewriting."""
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 ...
34 @property
35 def end_offset(self) -> int:
36 """Character offset just past this node's own text."""
37 ...
39 @property
40 def extended_end_offset(self) -> int:
41 """Character offset just past this node including trailing trivia."""
42 ...
44 @property
45 def filename(self) -> str:
46 """Name of the source file this node was parsed from."""
47 ...
49 @property
50 def parent(self) -> Self | None:
51 """Enclosing node, or None at the root."""
52 ...
54 @property
55 def text(self) -> str:
56 """Source text of this node."""
57 ...
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)?
67DEFAULT_INDENT = 4
70class ASTRewriter:
71 """AI: Rewriter that translates AST-level replace/remove/insert actions into byte-level edits."""
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
84 def get_filename(self) -> str:
85 """AI: Return the filename of the AST node this rewriter operates on."""
86 return self.__filename
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 )
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)
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 )
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 )
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()
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()
155 def has_changed(self) -> bool:
156 """AI: Return whether any rewrites have been queued."""
157 return len(self.__rewrites.rewrites) > 0
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)
164class _RewriteAction:
165 """Data container for a rewrite action to be applied later on to the AST."""
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
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 []
201class _RewriteActions:
202 """Data container for a list of rewrite actions to be applied later on to the AST."""
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
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)
230 def add_rewrite(self, rewrite: _RewriteAction) -> None:
231 self.rewrites.append(rewrite)
233 def apply(self) -> bytes:
234 self.__check_for_conflicting_rewrites()
235 rewriter = Rewriter(self.content[:])
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()
278 def apply_to_string(self) -> str:
279 return self.apply().decode(self.encoding)
281 def __check_for_conflicting_rewrites(self) -> None:
282 """Raise if two queued replace/remove rewrites target overlapping source ranges.
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.
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 )
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.
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
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.
322 Args:
323 node (Rewritable): The node to check.
325 Returns:
326 bool: True if the node is a descendant of any nodes in the rewrite list, False otherwise.
328 """
329 rewrite_nodes = list(flatten(rewrite.nodes for rewrite in self.rewrites))
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)
341 result = any(no_conflict(node, rew) for rew in rewrite_nodes)
343 return result and False # TODO: Why `and False`
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.
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.
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)
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.
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.
397 """
398 if not nodes:
399 return
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, "")
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
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)
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)
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.
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.
476 """
477 rewriter.replace(start, end, new_content.encode(self.encoding))
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)
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
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)
532 def __get_text(self, node: Rewritable) -> str:
533 if self._should_skip(node):
534 return ""
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
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
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))
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
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
607 def cor_offset(self, offset: int):
608 return offset - self.node.offset
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.
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
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
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.
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
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
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