-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtext_editor_kit.py
More file actions
348 lines (267 loc) · 13.5 KB
/
Copy pathtext_editor_kit.py
File metadata and controls
348 lines (267 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
import re
from collections import Counter
from typing import NamedTuple, Protocol, runtime_checkable
from math import gcd
from cedarscript_ast_parser import Marker, RelativeMarker, RelativePositionType, Segment, MarkerType, BodyOrWhole
MATCH_TYPES = ('exact', 'stripped', 'normalized', 'partial')
class MarkerMatchResult(NamedTuple):
match_type: str
index: int
indent: int
def __str__(self):
return f"{self.match_type.lower()} @ {self.index} ({self.indent})"
class IndexBoundaries(NamedTuple):
start: MarkerMatchResult
end: MarkerMatchResult
class SearchRange(NamedTuple):
start: int
end: int
indent: int = 0
class FunctionBoundaries(NamedTuple):
whole: SearchRange
body: SearchRange
# TODO Derive these 3 attrs from search ranges below
@property
def start_line(self) -> int:
return self.whole.start + 1
@property
def body_start_line(self) -> int:
return self.body.start + 1
@property
def end_line(self) -> int:
return self.whole.end
def read_file(file_path: str) -> str:
with open(file_path, 'r') as file:
return file.read()
def write_file(file_path: str, lines: list[str]):
with open(file_path, 'w') as file:
file.writelines([line + '\n' for line in lines])
class IndentationInfo(NamedTuple):
char_count: int
char: str
min_indent_level: int
consistency: bool = True
message: str | None = None
def level_difference(self, base_indentation_count: int):
return self.char_count_to_level(base_indentation_count) - self.min_indent_level
def char_count_to_level(self, char_count: int) -> int:
return char_count // self.char_count
def level_to_chars(self, level: int) -> str:
return level * self.char_count * self.char
def adjust_indentation(self, lines: list[str], base_indentation_count: int) -> list[str]:
line_adjuster = self._adjust_indentation_fun(base_indentation_count)
# Return the transformed lines
return [line_adjuster(line) for line in lines]
def _adjust_indentation_fun(self, base_indentation_count: int):
# Calculate the indentation difference
level_difference = self.level_difference(base_indentation_count)
def adjust_line(line: str) -> str:
if not line.strip():
# Handle empty lines or lines with only whitespace
return line
current_indent = get_line_indent_count(line)
current_level = self.char_count_to_level(current_indent)
new_level = max(0, current_level + level_difference)
new_indent = self.level_to_chars(new_level)
return new_indent + line.lstrip()
return adjust_line
def get_line_indent_count(line: str):
return len(line) - len(line.lstrip())
def count_leading_chars(line: str, char: str) -> int:
return len(line) - len(line.lstrip(char))
def normalize_line(line: str):
return re.sub(r'[^\w]', '.', line.strip(), flags=re.UNICODE)
def bow_to_search_range(bow: BodyOrWhole, searh_range: FunctionBoundaries | SearchRange | None = None, lines: list[str] | None = None) -> SearchRange:
match searh_range:
case SearchRange() | None:
return searh_range or SearchRange(0, -1, 0)
case FunctionBoundaries() as function_boundaries:
match bow:
case BodyOrWhole.BODY:
return function_boundaries.body
case BodyOrWhole.WHOLE:
return function_boundaries.whole
case _ as invalid:
raise ValueError(f"Invalid: {invalid}")
case _ as invalid:
raise ValueError(f"Invalid: {invalid}")
# MarkerOrSegment
# class MarkerOrSegmentProtocol(Protocol):
# def marker_or_segment_to_index_range(self) -> str:
# ...
@runtime_checkable
class MarkerOrSegmentProtocol(Protocol):
def marker_or_segment_to_index_range(
self,
lines: list[str],
search_start_index: int = 0, search_end_index: int = -1
) -> SearchRange:
...
def marker_or_segment_to_index_range_impl(
self,
lines: list[str],
search_start_index: int = 0, search_end_index: int = -1
) -> SearchRange | None:
match self:
case Marker(type=MarkerType.LINE):
result = find_line_index_and_indent(lines, self, search_start_index, search_end_index)
assert result, f"Unable to find `{self}`; Try: 1) Double-checking the marker (maybe you specified the the wrong one); or 2) using *exactly* the same characters from source; or 3) using another marker"
return SearchRange(result.index, result.index + 1, result.indent)
case Segment(start=s, end=e):
result = segment_to_indexes(lines, s, e, search_start_index, search_end_index)
return SearchRange(result.start.index, result.end.index, result.start.indent)
case _ as invalid:
raise ValueError(f"Unexpected type: {invalid}")
Marker.marker_or_segment_to_index_range = marker_or_segment_to_index_range_impl
Segment.marker_or_segment_to_index_range = marker_or_segment_to_index_range_impl
def find_line_index_and_indent(
lines: list[str],
search_term: Marker | RelativeMarker,
search_start_index: int = 0, search_end_index: int = -1
) -> MarkerMatchResult | None:
"""
Find the index of a specified line within a list of strings, considering different match types and an offset.
This function searches for a given line within a list, considering 4 types of matches in order of priority:
1. Exact match
2. Stripped match (ignoring leading and trailing whitespace)
3. Normalized match (ignoring non-alphanumeric characters)
4. Partial (Searching for a substring, using `casefold` to ignore upper- and lower-case differences.
The function applies the offset across all match types while maintaining the priority order.
:Args:
:param lines: The list of strings to search through.
:param search_term:
search_marker.value: The line to search for.
search_marker.offset: The number of matches to skip before returning a result.
0 skips no match and returns the first match, 1 returns the second match, and so on.
:param search_start_index: The index to start the search from. Defaults to 0.
:param search_end_index: The index to end the search at (exclusive).
Defaults to -1, which means search to the end of the list.
:returns:
MarkerMatchResult: The index for the desired line in the 'lines' list.
Returns None if no match is found or if the offset exceeds the number of matches within each category.
:Example:
>> lines = ["Hello, world!", " Hello, world! ", "Héllo, wörld?", "Another line", "Hello, world!"]
>> _find_line_index(lines, "Hello, world!", 1)
4 # Returns the index of the second exact match
Note:
- The function prioritizes match types in the order: exact, stripped, normalized, partial.
- The offset is considered separately for each type.
"""
search_line = search_term.value
assert search_line, "Empty marker"
assert search_term.type == MarkerType.LINE, f"Invalid marker type: {search_term.type}"
matches = {t: [] for t in MATCH_TYPES}
stripped_search = search_line.strip()
normalized_search_line = normalize_line(stripped_search)
if search_start_index < 0:
search_start_index = 0
if search_end_index < 0:
search_end_index = len(lines)
assert search_start_index < len(lines), f"search start index ({search_start_index}) must be less than line count ({len(lines)})"
assert search_end_index <= len(lines), f"search end index ({search_end_index}) must be less than or equal to line count ({len(lines)})"
for i in range(search_start_index, search_end_index):
line = lines[i]
reference_indent = get_line_indent_count(line)
# Check for exact match
if search_line == line:
matches['exact'].append((i, reference_indent))
# Check for stripped match
elif stripped_search == line.strip():
matches['stripped'].append((i, reference_indent))
# Check for normalized match
elif normalized_search_line == normalize_line(line):
matches['normalized'].append((i, reference_indent))
# Last resort!
elif normalized_search_line.casefold() in normalize_line(line).casefold():
matches['partial'].append((i, reference_indent))
offset = search_term.offset or 0
for match_type in MATCH_TYPES:
if offset < len(matches[match_type]):
index, reference_indent = matches[match_type][offset]
match match_type:
case 'normalized':
print(f'Note: using {match_type} match for {search_term}')
case 'partial':
print(f"Note: Won't accept {match_type} match at index {index} for {search_term}")
continue
if isinstance(search_term, RelativeMarker):
match search_term.qualifier:
case RelativePositionType.BEFORE:
index += -1
case RelativePositionType.AFTER:
index += 1
case RelativePositionType.AT:
pass
case _ as invalid:
raise ValueError(f"Not implemented: {invalid}")
return MarkerMatchResult(match_type, index, reference_indent)
return None
def segment_to_indexes(
lines: list[str],
start_relpos: RelativeMarker, end_relpos: RelativeMarker,
search_start_index: int = 0, search_end_index: int = -1
) -> IndexBoundaries:
assert len(lines), "`lines` is empty"
start_match_result = find_line_index_and_indent(lines, start_relpos, search_start_index, search_end_index)
assert start_match_result, f"Unable to find segment start \"{start_relpos}\"; Try: 1) Double-checking the marker (maybe you specified the the wrong one); or 2) using *exactly* the same characters from source; or 3) using a marker from above"
end_match_result = find_line_index_and_indent(lines, end_relpos, start_match_result.index, search_end_index)
if end_match_result:
if end_match_result.index > -1:
end_match_result = end_match_result._replace(index=end_match_result.index+1)
assert end_match_result, f"Unable to find segment end \"{end_relpos}\" - Try: 1) using *exactly* the same characters from source; or 2) using a marker from below"
return IndexBoundaries(start_match_result, end_match_result)
def normalize_indent(content: str, context_indent_count: int = 0, indentation_info: IndentationInfo | None = None) -> list[str]:
# TODO Always send str?
lines = [line.lstrip() for line in content.splitlines() if line.strip()] if isinstance(content, str) else content
context_indent_level = indentation_info.char_count_to_level(context_indent_count)
for i in range(len(lines)):
line = lines[i]
parts = line.split(':', 1)
if len(parts) == 2 and parts[0].startswith('@'):
relative_indent_level = int(parts[0][1:])
absolute_indent_level = context_indent_level + relative_indent_level
assert absolute_indent_level >= 0, f"Final indentation for line `{line.strip()}` cannot be negative ({absolute_indent_level})"
lines[i] = indentation_info.level_to_chars(absolute_indent_level) + parts[1].lstrip()
else:
absolute_indent_level = context_indent_level
lines[i] = indentation_info.level_to_chars(absolute_indent_level) + line.lstrip()
return lines
def analyze_indentation(lines: list[str]) -> IndentationInfo:
def extract_indentation(line: str) -> str:
return re.match(r'^\s*', line).group(0)
indentations = [extract_indentation(line) for line in lines if line.strip()]
if not indentations:
return IndentationInfo(4, ' ', 0, True, "No indentation found. Assuming 4 spaces (PEP 8).")
indent_chars = Counter(indent[0] for indent in indentations if indent)
dominant_char = ' ' if indent_chars.get(' ', 0) >= indent_chars.get('\t', 0) else '\t'
indent_lengths = [len(indent) for indent in indentations]
if dominant_char == '\t':
char_count = 1
else:
# For spaces, determine the most likely char_count
space_counts = [len for len in indent_lengths if len % 2 == 0 and len > 0]
if not space_counts:
char_count = 2 # Default to 2 if no even space counts
else:
# Sort top 5 space counts and find the largest GCD
sorted_counts = sorted([c[0] for c in Counter(space_counts).most_common(5)], reverse=True)
char_count = sorted_counts[0]
for i in range(1, len(sorted_counts)):
new_gcd = gcd(char_count, sorted_counts[i])
if new_gcd <= 1:
break
char_count = new_gcd
min_indent_chars = min(indent_lengths) if indent_lengths else 0
min_indent_level = min_indent_chars // char_count
consistency = all(len(indent) % char_count == 0 for indent in indentations if indent)
match dominant_char:
case ' ':
domcharstr = 'space'
case '\t':
domcharstr = 'tab'
case _:
domcharstr = dominant_char
message = f"Found {char_count}-{domcharstr} indentation"
if not consistency:
message += " (inconsistent)"
return IndentationInfo(char_count, dominant_char, min_indent_level, consistency, message)