Skip to content

Commit 31cf22c

Browse files
authored
Merge pull request #872 from python-rope/lieryan-implement-ast-lines-adapter
Fix unicode handling in patched ast
2 parents f005ac7 + acf6100 commit 31cf22c

5 files changed

Lines changed: 510 additions & 58 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
- #850 Update and pin black version in pre-commit and Github Actions
44
- #851 Bump supported python version to up to Python 3.14
5-
- #852 Implement patchedast handlers for TypeAlias
5+
- #852 Implement patchedast handlers for TypeAlias
66
- #853 Implement patchedast handlers TypeVar
77
- #847 Avoid printing autoimport syntax errors (@yangfan-yf-yf)
88
- #623, #819, #863 Support MatchOr, MatchSequence, MatchStar (@jheld, @lieryan)
99
- #870 Add default implementation for is_dir() (@lieryan)
10+
- #872 Fix unicode handling in patchedast (@lieryan)
1011

1112
# Release 1.14.0
1213

‎rope/base/codeanalyze.py‎

Lines changed: 138 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -70,24 +70,147 @@ def get_line_start(self, lineno):
7070
def get_line_end(self, lineno):
7171
return self.starts[lineno] - 1
7272

73-
def __getitem__(self, subscript):
74-
start_offset = self._calculate_offset(subscript.start)
75-
stop_offset = self._calculate_offset(subscript.stop)
76-
return self.code[start_offset:stop_offset]
77-
78-
def _calculate_offset(self, coord: tuple[int, int]) -> int:
79-
lineno, col_offset = coord
80-
lineno = self._clamp(0, self.length(), lineno)
81-
col_offset = self._clamp(
82-
0,
83-
self.get_line_end(lineno) - self.get_line_start(lineno),
84-
col_offset,
73+
74+
class ASTLinesAdapter:
75+
"""
76+
Convert between stdlib's ast `col_offset` and rope's `region` offsets.
77+
78+
ast `col_offset` are given in byte offset of the utf-8 source code from
79+
the CPython interpreter, this is not necessarily identical to the str
80+
offset (i.e. unicode codepoint offset) that rope uses.
81+
82+
Semantics are intended to match ``ast.get_source_segment`` exactly,
83+
including its quirks:
84+
85+
- Line numbers are 1-indexed (``node.lineno`` / ``node.end_lineno``),
86+
matching the ``ast`` module.
87+
- Column offsets are UTF-8 *byte* offsets into the line, not character
88+
offsets -- this matters for any line containing non-ASCII characters.
89+
- Line splitting uses the same boundaries the CPython parser uses
90+
(``\\n``, ``\\r\\n``, ``\\r``) rather than the broader set recognized by
91+
``str.splitlines()`` (form feed, vertical tab, etc.), since those extra
92+
separators don't correspond to a new line as far as ``lineno`` is
93+
concerned.
94+
"""
95+
96+
class LineNumberOutOfRange(IndexError):
97+
pass
98+
99+
# Same pattern rule as `ast._line_pattern` used by `ast._splitlines_no_ff`
100+
# to split lines in a way that mimics how the CPython parser splits source
101+
# code.
102+
LINE_PATTERN_STR = re.compile("(.*?(?:\r\n|\n|\r|$))")
103+
LINE_PATTERN_BYTES = re.compile(b"(.*?(?:\r\n|\n|\r|$))")
104+
105+
def __init__(self, source: str):
106+
self._code_str: str = source
107+
self._code_bytes: bytes = source.encode("utf-8")
108+
self._starts_str: list[int] = self._initialize_line_starts(
109+
self.LINE_PATTERN_STR, self._code_str
85110
)
111+
self._starts_bytes: list[int] = self._initialize_line_starts(
112+
self.LINE_PATTERN_BYTES, self._code_bytes
113+
)
114+
115+
@classmethod
116+
def _initialize_line_starts(cls, pattern, source) -> list[int]:
117+
matches = list(pattern.finditer(source))
118+
if len(matches) > 1 and matches[-1].start() == matches[-1].end():
119+
matches.pop()
120+
starts = [m.start() for m in matches]
121+
if source[-1:] in [b"\n", b"\r", "\n", "\r"]:
122+
starts.append(len(source))
123+
else:
124+
starts.append(len(source) + 1)
125+
return starts
126+
127+
def __len__(self):
128+
return len(self._starts_bytes) - 1
129+
130+
def _validate_lineno(self, lineno: int) -> int:
131+
if not (0 < lineno <= len(self)):
132+
raise ASTLinesAdapter.LineNumberOutOfRange(
133+
f"Line number out of range: {lineno}"
134+
)
135+
return lineno - 1
136+
137+
def _get_line_text(self, line_idx: int, col_offset: int | None = None) -> str:
138+
return self._get_line_bytes(line_idx, col_offset).decode("utf-8")
139+
140+
def _get_line_bytes(self, line_idx: int, col_offset: int | None = None) -> bytes:
141+
start = self._starts_bytes[line_idx]
142+
end = self._starts_bytes[line_idx + 1]
143+
if col_offset is not None:
144+
end = min(end, start + col_offset)
145+
return self._code_bytes[start:end].rstrip(b"\r\n")
146+
147+
def _is_line_ascii_only(self, line_idx: int):
148+
start_str = self._starts_str[line_idx]
149+
end_str = self._starts_str[line_idx + 1]
150+
start_bytes = self._starts_bytes[line_idx]
151+
end_bytes = self._starts_bytes[line_idx + 1]
152+
# str length == bytes length iff they're ascii-only because any
153+
# non-ascii characters would be at least 2 bytes
154+
return (end_str - start_str) == (end_bytes - start_bytes)
155+
156+
def _line_region_offset(self, line_idx: int, col_offset: int) -> int:
157+
"""str offset relative to the start of line"""
158+
if col_offset == 0 or self._is_line_ascii_only(line_idx):
159+
return col_offset
160+
prefix = self._get_line_text(line_idx, col_offset)
161+
return len(prefix)
162+
163+
def _absolute_region_offset(self, line_idx: int, col_offset: int) -> int:
164+
"""str offset relative to the start of file"""
165+
line_offset = self._line_region_offset(line_idx, col_offset)
166+
return self._starts_str[line_idx] + line_offset
167+
168+
@classmethod
169+
def _pad_whitespace(cls, source: str) -> str:
170+
"""Equivalent to `ast._pad_whitespace(source)`"""
171+
return "".join(c if c in "\f\t" else " " for c in source)
172+
173+
def get_line(self, lineno: int) -> str:
174+
line_idx = self._validate_lineno(lineno)
175+
return self._get_line_text(line_idx)
176+
177+
def get_source_segment(self, node, *, padded: bool = False) -> str | None:
178+
"""Equivalent to ``ast.get_source_segment(source, node, padded=padded)``.
179+
180+
``ast.get_source_segment(source, node)`` is convenient, but every call
181+
re-splits the *entire* source string into lines (via the private
182+
``ast._splitlines_no_ff``) and re-encodes whatever lines it touches to
183+
UTF-8. If you're pulling source segments for many nodes out of the same
184+
file (e.g. walking a whole AST), that's a lot of repeated O(n) work.
185+
"""
186+
187+
region_start, region_end = self[node]
188+
if region_start is None and region_end is None:
189+
return None
190+
source_segment = self._code_str[region_start:region_end]
191+
192+
if padded and node.lineno != node.end_lineno:
193+
line_idx = node.lineno - 1
194+
prefix = self._get_line_text(line_idx, node.col_offset)
195+
padding = self._pad_whitespace(prefix)
196+
source_segment = padding + source_segment
86197

87-
return self.get_line_start(lineno) + col_offset
198+
return source_segment
88199

89-
def _clamp(self, min_value, max_value, value):
90-
return max(min_value, min(max_value, value))
200+
def __getitem__(self, node) -> tuple[int, int] | tuple[None, None]:
201+
try:
202+
if node.end_lineno is None or node.end_col_offset is None:
203+
return (None, None)
204+
line_idx = node.lineno - 1
205+
end_line_idx = node.end_lineno - 1
206+
col_offset = node.col_offset
207+
end_col_offset = node.end_col_offset
208+
except AttributeError:
209+
return (None, None)
210+
211+
region_start = self._absolute_region_offset(line_idx, col_offset)
212+
region_end = self._absolute_region_offset(end_line_idx, end_col_offset)
213+
return (region_start, region_end)
91214

92215

93216
class ArrayLinesAdapter:

‎rope/refactor/patchedast.py‎

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def __init__(self, source, children=False):
6767
self.source = _Source(source)
6868
self.children = children
6969
self.lines = codeanalyze.SourceLinesAdapter(source)
70+
self.ast_adapter = codeanalyze.ASTLinesAdapter(source)
7071
self.children_stack = []
7172

7273
Number = object()
@@ -217,7 +218,8 @@ def _find_next_statement_start(self):
217218
for children in reversed(self.children_stack):
218219
for child in children:
219220
if isinstance(child, ast.stmt):
220-
return child.col_offset + self.lines.get_line_start(child.lineno)
221+
start, _ = self.ast_adapter[child]
222+
return start
221223
return len(self.source.source)
222224

223225
def _join(self, iterable, separator):
@@ -576,11 +578,8 @@ def _If(self, node):
576578
def _is_elif(self, node):
577579
if not isinstance(node, ast.If):
578580
return False
579-
offset = self.lines.get_line_start(node.lineno) + node.col_offset
580-
word = self.source[offset : offset + 4]
581-
# XXX: This is a bug; the offset does not point to the first
582-
alt_word = self.source[offset - 5 : offset - 1]
583-
return "elif" in (word, alt_word)
581+
start, end = self.ast_adapter[node]
582+
return "elif" in self.source[start : start + 4]
584583

585584
def _IfExp(self, node):
586585
return self._handle(node, [node.body, "if", node.test, "else", node.orelse])
@@ -813,27 +812,23 @@ def _MatchSequence(self, node):
813812
*closing_paren,
814813
]
815814
else:
816-
node_start = (node.lineno, node.col_offset)
817-
node_end = (node.end_lineno, node.end_col_offset)
818-
children = [self.lines[node_start:node_end]]
815+
empty_tuple = self.ast_adapter.get_source_segment(node)
816+
children = [empty_tuple]
819817
self._handle(node, children)
820818

821819
def _get_surrounding_parens(self, node: ast.MatchSequence):
822-
node_start = (node.lineno, node.col_offset)
823-
first_pattern_start = (node.patterns[0].lineno, node.patterns[0].col_offset)
824-
opening_paren = self.lines[node_start:first_pattern_start].strip()
820+
node_start, node_end = self.ast_adapter[node]
821+
first_pattern_start, _ = self.ast_adapter[node.patterns[0]]
822+
_, last_pattern_end = self.ast_adapter[node.patterns[-1]]
823+
opening_paren = self.source[node_start:first_pattern_start].strip()
824+
closing_paren = self.source[last_pattern_end:node_end].strip()
825+
825826
if opening_paren not in ["[", "(", ""]:
826827
warnings.warn(
827828
f"Unexpected character in MatchSequence's opening_paren <{opening_paren}>; please report!",
828829
RuntimeWarning,
829830
)
830831

831-
last_pattern_end = (
832-
node.patterns[-1].end_lineno,
833-
node.patterns[-1].end_col_offset,
834-
)
835-
node_end = (node.end_lineno, node.end_col_offset)
836-
closing_paren = self.lines[last_pattern_end:node_end].strip()
837832
if closing_paren not in ["]", ")", ""]:
838833
warnings.warn(
839834
f"Unexpected character in MatchSequence's closing_paren <{closing_paren}>; please report!",
@@ -1015,9 +1010,6 @@ def find_backwards(self, pattern, offset):
10151010
def __getitem__(self, index):
10161011
return self.source[index]
10171012

1018-
def __getslice__(self, i, j):
1019-
return self.source[i:j]
1020-
10211013
def _get_number_pattern(self):
10221014
# HACK: It is merely an approaximation and does the job
10231015
integer = r"\-?(0[xo][\da-fA-F]+|\d+)"

0 commit comments

Comments
 (0)