@@ -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
93216class ArrayLinesAdapter :
0 commit comments